init commit

This commit is contained in:
Fellowship Scholar 2026-03-29 20:07:56 +00:00
commit f6a5823439
341 changed files with 104937 additions and 0 deletions

10
.env Normal file
View File

@ -0,0 +1,10 @@
CADDY_DOMAIN=fellowship-tutorial1-admin-14.fellowship.testingfantasy.com
JENKINS_DOMAIN=jenkins-fellowship-tutorial1-admin-14.fellowship.testingfantasy.com
IDE_DOMAIN=ide-fellowship-tutorial1-admin-14.fellowship.testingfantasy.com
GITEA_DOMAIN=gitea-fellowship-tutorial1-admin-14.fellowship.testingfantasy.com
MACHINE_NAME=fellowship-tutorial1-admin-14
WORKSHOP_NAME=fellowship
ROUTE53_ZONE_ID=Z00868603TVDTV1H4G9BU
CADDYFILE_PATH=./caddy/Caddyfile.fellowship
FRONTEND_MODE=prod
WDS_SOCKET_PROTOCOL=wss

33
.env.example Normal file
View File

@ -0,0 +1,33 @@
# Environment Configuration Template
# File: .env.example (DO NOT COMMIT ACTUAL .env FILES)
#
# Copy one of the environment templates below based on your use case:
#
# Production (EC2 host): cp .env.prod .env
# Local Development (IDE): Automatically set by code-server container
# OR manually: cp .env.local .env
#
# DO NOT commit .env files to git — they may contain secrets.
# See .gitignore for exclusion rules.
##############################################################################
# PRODUCTION BUILD (EC2 Host Deployment)
##############################################################################
COMPOSE_PROJECT_NAME=fellowship
FLASK_ENV=production
NODE_ENV=production
FRONTEND_MODE=prod
CADDY_DOMAIN=fellowship.classroom.local
CADDYFILE_PATH=./caddy/Caddyfile
##############################################################################
# LOCAL DEVELOPMENT BUILD (IDE Terminal)
##############################################################################
# COMPOSE_PROJECT_NAME=fellowship-local
# FLASK_ENV=development
# NODE_ENV=development
# FRONTEND_MODE=dev
# CADDY_DOMAIN=localhost
# CADDYFILE_PATH=./caddy/Caddyfile.local

60
.env.local Normal file
View File

@ -0,0 +1,60 @@
# Local Development Environment Configuration
# Used in code-server IDE terminal for testing changes before deployment
# File: .env.local
# Usage: Automatically set by code-server container via COMPOSE_PROJECT_NAME env var
# OR manually: cp .env.local .env && docker-compose up -d
# Docker Compose Project Naming
# Using "fellowship-local" isolates containers from production ("fellowship-*")
# Containers: fellowship-local_backend_1, fellowship-local_frontend_1, etc.
# Volumes: fellowship-local_backend_data, fellowship-local_frontend_node_modules, etc.
# Networks: fellowship-local_default
COMPOSE_PROJECT_NAME=fellowship-local
# Backend/Flask Configuration
FLASK_APP=app.py
FLASK_ENV=development
DATABASE_URL=sqlite:////app/data/fellowship.db
SECRET_KEY=dev-secret-key-change-in-production
# Frontend/React Configuration
NODE_ENV=development
FRONTEND_MODE=dev
REACT_APP_API_URL=/api
REACT_APP_DISABLE_ANALYTICS=true
CHOKIDAR_USEPOLLING=true
SKIP_PREFLIGHT_CHECK=true
DISABLE_ESLINT_PLUGIN=true
FAST_REFRESH=false
# Caddy/Reverse Proxy Configuration
# Local development uses HTTP-only (Caddyfile.local)
CADDY_DOMAIN=localhost
CADDYFILE_PATH=./caddy/Caddyfile.local
WDS_SOCKET_PORT=80
WDS_SOCKET_PROTOCOL=
# DevOps Escape Room Subdomains (empty for local/HTTP)
JENKINS_DOMAIN=
IDE_DOMAIN=
GITEA_DOMAIN=
# Jenkins Configuration
JENKINS_ADMIN_PASSWORD=fellowship123
JENKINS_URL=http://localhost:8080/
# Gitea Configuration
GITEA_ADMIN_USER=fellowship
GITEA_ADMIN_PASSWORD=fellowship123
GITEA_ADMIN_EMAIL=gandalf@fellowship.local
GITEA_DOMAIN=localhost
GITEA_ROOT_URL=http://localhost:3030/
# code-server IDE Configuration
CODESERVER_PASSWORD=fellowship
# Optional: Azure OpenAI Integration (disabled for local dev)
AZURE_OPENAI_ENDPOINT=
AZURE_OPENAI_API_KEY=
AZURE_OPENAI_DEPLOYMENT=
AZURE_OPENAI_API_VERSION=

61
.env.prod Normal file
View File

@ -0,0 +1,61 @@
# Production Environment Configuration
# Used on EC2 host instance for classroom SUT deployment
# File: .env.prod
# Usage: cp .env.prod .env && docker-compose up -d
# Docker Compose Project Naming
# Default (no project name) → containers, networks, volumes named "fellowship_*"
COMPOSE_PROJECT_NAME=fellowship
# Backend/Flask Configuration
FLASK_APP=app.py
FLASK_ENV=production
DATABASE_URL=sqlite:////app/data/fellowship.db
SECRET_KEY=${SECRET_KEY:-change-me-in-production}
# Frontend/React Configuration
NODE_ENV=production
FRONTEND_MODE=prod
REACT_APP_API_URL=/api
REACT_APP_DISABLE_ANALYTICS=false
# WebSocket Configuration for Webpack Dev Server (when FRONTEND_MODE=dev)
# For development with HTTPS proxy (Caddy): use wss
# For CI/HTTP environments: use ws
# Default: ws for HTTP environments
WDS_SOCKET_PROTOCOL=ws
WDS_SOCKET_PORT=80
WDS_SOCKET_HOST=localhost
WDS_SOCKET_PATH=/ws
# Caddy/Reverse Proxy Configuration
# CADDY_DOMAIN: Root domain for the SUT (e.g., fellowship.classroom.local)
# Set by setup_fellowship.sh during instance bootstrap
CADDY_DOMAIN=${CADDY_DOMAIN:-localhost}
CADDYFILE_PATH=${CADDYFILE_PATH:-./caddy/Caddyfile}
# DevOps Escape Room Subdomains (for Jenkins, IDE, Gitea)
# Set by setup_fellowship.sh when CADDY_DOMAIN is known
JENKINS_DOMAIN=${JENKINS_DOMAIN:-}
IDE_DOMAIN=${IDE_DOMAIN:-}
GITEA_DOMAIN=${GITEA_DOMAIN:-}
# Jenkins Configuration
JENKINS_ADMIN_PASSWORD=${JENKINS_ADMIN_PASSWORD:-fellowship123}
JENKINS_URL=${JENKINS_URL:-http://localhost:8080/}
# Gitea Configuration
GITEA_ADMIN_USER=${GITEA_ADMIN_USER:-fellowship}
GITEA_ADMIN_PASSWORD=${GITEA_ADMIN_PASSWORD:-fellowship123}
GITEA_ADMIN_EMAIL=${GITEA_ADMIN_EMAIL:-gandalf@fellowship.local}
GITEA_DOMAIN=${GITEA_DOMAIN:-localhost}
GITEA_ROOT_URL=${GITEA_ROOT_URL:-http://localhost:3030/}
# code-server IDE Configuration
CODESERVER_PASSWORD=${CODESERVER_PASSWORD:-fellowship}
# Optional: Azure OpenAI Integration
AZURE_OPENAI_ENDPOINT=${AZURE_OPENAI_ENDPOINT:-}
AZURE_OPENAI_API_KEY=${AZURE_OPENAI_API_KEY:-}
AZURE_OPENAI_DEPLOYMENT=${AZURE_OPENAI_DEPLOYMENT:-}
AZURE_OPENAI_API_VERSION=${AZURE_OPENAI_API_VERSION:-}

18
caddy/Caddyfile Normal file
View File

@ -0,0 +1,18 @@
# Caddyfile for Fellowship SUT - STAGING
# Domain will be dynamically set via environment variable CADDY_DOMAIN
# Uses Let's Encrypt Staging CA to avoid rate limits (up to 5,000 cert/hour)
# For local development: CADDY_DOMAIN defaults to localhost
# For production certificates, use Caddyfile.prod instead
# For tutorial instances (SUT + Jenkins + IDE), use Caddyfile.fellowship instead
{$CADDY_DOMAIN:localhost} {
# Use Let's Encrypt staging CA for development and testing
# Staging certs won't be trusted by browsers but avoid rate limits
# Caddy automatically uses self-signed certs for localhost
tls {
ca https://acme-staging-v02.api.letsencrypt.org/directory
}
reverse_proxy /api/* backend:5000
reverse_proxy /* frontend:3000
}

View File

@ -0,0 +1,54 @@
# Caddyfile for Fellowship Tutorial Instances
# Used exclusively by setup_fellowship.sh for classroom/tutorial EC2 instances
# that run the full DevOps Escape Room stack (SUT + Jenkins CI + code-server IDE).
#
# This file is NEVER used by the permanent SUT deployment (bootstrap_spot_instance.sh),
# which uses Caddyfile.prod (SUT only) instead.
#
# setup_fellowship.sh copies this file over caddy/Caddyfile before starting
# docker compose, so that the Caddy container picks it up automatically.
#
# Required environment variables:
# CADDY_DOMAIN SUT domain (e.g. fellowship-pool-8.fellowship.testingfantasy.com)
# JENKINS_DOMAIN Jenkins domain (jenkins-{CADDY_DOMAIN})
# IDE_DOMAIN IDE domain (ide-{CADDY_DOMAIN})
# GITEA_DOMAIN Gitea domain (gitea-{CADDY_DOMAIN})
#
# All four domains must have Route53 A records pointing to the same instance
# public IP as CADDY_DOMAIN. setup_fellowship.sh creates all records.
#
# Routing:
# CADDY_DOMAIN SUT frontend (port 3000) and backend API (port 5000)
# JENKINS_DOMAIN Jenkins CI (port 8080, devops-escape-room compose stack)
# IDE_DOMAIN code-server (port 8443, devops-escape-room compose stack)
# GITEA_DOMAIN Gitea (port 3030, devops-escape-room compose stack)
#
# Jenkins and code-server are reached via host.docker.internal (host-gateway),
# because they run in a separate docker-compose project from Caddy.
# docker-compose.yml sets extra_hosts: [host.docker.internal:host-gateway].
# ── Fellowship SUT ────────────────────────────────────────────────────────────
{$CADDY_DOMAIN} {
# Let Caddy use its default automatic HTTPS issuers.
# This avoids hard-failing when a single ACME CA is temporarily rate-limited.
reverse_proxy /api/* backend:5000
reverse_proxy /* frontend:3000
}
# ── Jenkins CI (DevOps Escape Room) ──────────────────────────────────────────
{$JENKINS_DOMAIN} {
reverse_proxy /* host.docker.internal:8080
}
# ── code-server IDE (DevOps Escape Room) ─────────────────────────────────────
# Host port 8443 maps to the code-server container's internal port 8080.
{$IDE_DOMAIN} {
reverse_proxy /* host.docker.internal:8443
}
# ── Gitea (self-hosted Git, DevOps Escape Room) ───────────────────────────────
# Host port 3030 maps to Gitea's internal port 3000.
{$GITEA_DOMAIN} {
reverse_proxy /* host.docker.internal:3030
}

18
caddy/Caddyfile.local Normal file
View File

@ -0,0 +1,18 @@
# Caddyfile for Fellowship SUT - LOCAL DEVELOPMENT
# HTTP-only configuration for local development (no HTTPS)
# Explicitly use http:// to avoid automatic HTTPS redirect
http://localhost, http://127.0.0.1, :80 {
# API routes - Flask handles CORS
handle /api/* {
reverse_proxy backend:5000
}
# WebSocket support
handle /ws {
reverse_proxy backend:5000
}
# Frontend routes
reverse_proxy /* frontend:3000
}

14
caddy/Caddyfile.prod Normal file
View File

@ -0,0 +1,14 @@
# Caddyfile for Fellowship SUT - PRODUCTION
# Domain will be dynamically set via environment variable CADDY_DOMAIN
# Uses Let's Encrypt Production CA for trusted certificates
# Rate limit: 50 certificates per domain per week
# For local development, use Caddyfile (staging) instead
# For tutorial instances (SUT + Jenkins + IDE), use Caddyfile.fellowship instead
{$CADDY_DOMAIN:localhost} {
# Let Caddy use its default automatic HTTPS issuers.
# This avoids hard-failing when a single ACME CA is temporarily rate-limited.
reverse_proxy /api/* backend:5000
reverse_proxy /* frontend:3000
}

21
devops-escape-room/.env Normal file
View File

@ -0,0 +1,21 @@
# Production Environment Configuration for DevOps Escape Room
# File: devops-escape-room/.env.prod
# Usage: cp .env.prod .env && docker-compose up -d
# Docker Compose Project Naming (production uses default "fellowship")
# Note: This is for the escape room stack only; the SUT stack may differ
COMPOSE_PROJECT_NAME=fellowship
# Jenkins Configuration
JENKINS_ADMIN_PASSWORD=fellowship123
JENKINS_URL=http://localhost:8080/
# Gitea Configuration
GITEA_ADMIN_USER=fellowship
GITEA_ADMIN_PASSWORD=fellowship123
GITEA_ADMIN_EMAIL=gandalf@fellowship.local
GITEA_DOMAIN=localhost
GITEA_ROOT_URL=http://localhost:3030/
# code-server IDE Configuration
CODESERVER_PASSWORD=fellowship

View File

@ -0,0 +1,22 @@
# Local Development Environment Configuration for DevOps Escape Room
# File: devops-escape-room/.env.local
# Usage: Automatically set by code-server container
# OR manually: cp .env.local .env && docker-compose up -d
# Docker Compose Project Naming
# Using "fellowship-local" isolates from production stacks
COMPOSE_PROJECT_NAME=fellowship-local
# Jenkins Configuration
JENKINS_ADMIN_PASSWORD=fellowship123
JENKINS_URL=http://localhost:8080/
# Gitea Configuration
GITEA_ADMIN_USER=fellowship
GITEA_ADMIN_PASSWORD=fellowship123
GITEA_ADMIN_EMAIL=gandalf@fellowship.local
GITEA_DOMAIN=localhost
GITEA_ROOT_URL=http://localhost:3030/
# code-server IDE Configuration
CODESERVER_PASSWORD=fellowship

View File

@ -0,0 +1,21 @@
# Production Environment Configuration for DevOps Escape Room
# File: devops-escape-room/.env.prod
# Usage: cp .env.prod .env && docker-compose up -d
# Docker Compose Project Naming (production uses default "fellowship")
# Note: This is for the escape room stack only; the SUT stack may differ
COMPOSE_PROJECT_NAME=fellowship
# Jenkins Configuration
JENKINS_ADMIN_PASSWORD=fellowship123
JENKINS_URL=http://localhost:8080/
# Gitea Configuration
GITEA_ADMIN_USER=fellowship
GITEA_ADMIN_PASSWORD=fellowship123
GITEA_ADMIN_EMAIL=gandalf@fellowship.local
GITEA_DOMAIN=localhost
GITEA_ROOT_URL=http://localhost:3030/
# code-server IDE Configuration
CODESERVER_PASSWORD=fellowship

View File

@ -0,0 +1,53 @@
# Fellowship code-server IDE
# Extends codercom/code-server with:
# - Docker CLI + Compose v2 plugin (so students can run docker compose from the IDE terminal)
# - Pre-installed VS Code extensions (Python, Playwright, Copilot, Jupyter, Prettier)
# - Runtime entrypoint that aligns the docker group GID with the host socket
FROM codercom/code-server:latest
USER root
# ── System packages ──────────────────────────────────────────────────────────
# docker.io provides the Docker CLI (client only); the daemon runs on the host.
# gosu is used in the entrypoint to drop privileges cleanly back to coder.
RUN apt-get update && \
apt-get install -y --no-install-recommends \
docker.io \
gosu \
curl \
git \
&& rm -rf /var/lib/apt/lists/*
# ── Docker Compose v2 plugin ─────────────────────────────────────────────────
# Install as a CLI plugin AND as a standalone binary so both
# `docker compose ...` and `docker-compose ...` work from the terminal.
RUN mkdir -p /usr/local/lib/docker/cli-plugins && \
curl -fsSL \
"https://github.com/docker/compose/releases/download/v2.27.0/docker-compose-linux-x86_64" \
-o /usr/local/lib/docker/cli-plugins/docker-compose && \
chmod +x /usr/local/lib/docker/cli-plugins/docker-compose && \
ln -sf /usr/local/lib/docker/cli-plugins/docker-compose /usr/local/bin/docker-compose && \
ln -sf /usr/local/lib/docker/cli-plugins/docker-compose /usr/local/bin/docker compose || true
# Make the CLI plugin available to the coder user too
RUN mkdir -p /home/coder/.docker/cli-plugins && \
ln -sf /usr/local/lib/docker/cli-plugins/docker-compose \
/home/coder/.docker/cli-plugins/docker-compose && \
chown -R coder:coder /home/coder/.docker
# ── Docker group ─────────────────────────────────────────────────────────────
# Pre-create the docker group with GID 999 (common default).
# The entrypoint will re-align the GID at runtime to match the host socket.
RUN groupadd -g 999 docker 2>/dev/null || groupmod -g 999 docker 2>/dev/null || true && \
usermod -aG docker coder
# ── Runtime entrypoint ───────────────────────────────────────────────────────
COPY entrypoint.sh /usr/bin/fellowship-docker-init.sh
RUN chmod +x /usr/bin/fellowship-docker-init.sh
# Run as root initially so the entrypoint can fix group GIDs.
# The entrypoint drops back to 'coder' via gosu before starting code-server.
USER root
ENTRYPOINT ["/usr/bin/fellowship-docker-init.sh"]

View File

@ -0,0 +1,181 @@
#!/bin/bash
# Fellowship code-server runtime entrypoint
# 1. Aligns the docker group GID with the host's /var/run/docker.sock
# 2. Pre-installs VS Code extensions (idempotent)
# 3. Drops to 'coder' user and starts code-server
set -e
log() { echo "[$(date '+%H:%M:%S')] [fellowship-init] $*"; }
# ── Fix Docker group GID ──────────────────────────────────────────────────────
# Without this, `docker` commands inside code-server fail with "permission denied"
# because the socket's GID on the host may differ from the GID baked into the image.
if [ -S /var/run/docker.sock ]; then
DOCK_GID=$(stat -c '%g' /var/run/docker.sock)
log "Host docker socket GID: ${DOCK_GID}"
if getent group docker > /dev/null 2>&1; then
groupmod -g "${DOCK_GID}" docker 2>/dev/null || true
else
groupadd -g "${DOCK_GID}" docker 2>/dev/null || true
fi
usermod -aG docker coder 2>/dev/null || true
# Make the socket world-accessible as a safe fallback
chmod 666 /var/run/docker.sock 2>/dev/null || true
log "✓ Docker group GID aligned to ${DOCK_GID}"
else
log "WARNING: /var/run/docker.sock not mounted — docker commands will not work in the IDE terminal"
log " Add - /var/run/docker.sock:/var/run/docker.sock to the code-server volumes."
fi
# ── Fix config directory permissions ──────────────────────────────────────────
# The codeserver_config volume is mounted as /home/coder/.config but is owned by root.
# Ensure coder user can read/write to it.
if [ -d /home/coder/.config ]; then
chown -R coder:coder /home/coder/.config 2>/dev/null || true
log "✓ Config directory permissions fixed"
fi
# ── Fix fellowship project directory permissions ───────────────────────────────
# The fellowship directory is mounted from the host and may be owned by ec2-user
# or root. Ensure the coder user can read/write all project files for IDE editing.
# This includes fixing .git directory ownership which git is sensitive about.
if [ -d /home/coder/fellowship ]; then
chown -R coder:coder /home/coder/fellowship 2>/dev/null || true
chmod -R u+rw /home/coder/fellowship 2>/dev/null || true
log "✓ Fellowship project directory permissions fixed"
# Fix .git directory specifically since git checks ownership strictly
if [ -d /home/coder/fellowship/.git ]; then
chown -R coder:coder /home/coder/fellowship/.git 2>/dev/null || true
chmod -R u+rw /home/coder/fellowship/.git 2>/dev/null || true
fi
fi
# ── Configure git safe.directory ──────────────────────────────────────────────
# Git requires explicit permission for directories owned by different users.
# Mark the fellowship project as a safe git repository for the coder user.
# We configure this even if .git doesn't exist yet — it will be used for new repos.
if [ -d /home/coder/fellowship ]; then
# Using su instead of gosu ensures proper shell environment and config file loading
su - coder -c "git config --global --add safe.directory /home/coder/fellowship" 2>/dev/null || true
log "✓ Git safe.directory configured for /home/coder/fellowship"
fi
# ── Install VS Code extensions ────────────────────────────────────────────────
# Runs as the coder user; uses Open VSX registry (code-server default).
# The --force flag makes installs idempotent — safe to run on every startup.
# ── Configure git defaults ───────────────────────────────────────────────────
# Set global git configuration for the coder user
log "Configuring git defaults..."
su - coder -c "git config --global user.name 'Fellowship Scholar'" 2>/dev/null || true
su - coder -c "git config --global user.email 'scholar@fellowship.local'" 2>/dev/null || true
su - coder -c "git config --global credential.helper store" 2>/dev/null || true
log "✓ Git configured: Fellowship Scholar <scholar@fellowship.local>"
# ── Configure Gitea as git remote ──────────────────────────────────────────────
# Ensure the fellowship repository is connected to Gitea (not GitHub or other remotes)
# This runs every startup to guarantee correct remote configuration
if [ -d /home/coder/fellowship ]; then
log "Ensuring git repository is connected to Gitea..."
if [ -d /home/coder/fellowship/.git ]; then
# Repository exists - check if it's the old GitHub repository
# Count branches: fresh Gitea repository only has main, old GitHub has 15+ branches
BRANCH_COUNT=$(su - coder -c "cd /home/coder/fellowship && git branch -r 2>/dev/null | wc -l" 2>/dev/null || echo "0")
BRANCH_COUNT=$(echo "$BRANCH_COUNT" | tr -d ' ' || echo "0") # Strip whitespace
if [ "$BRANCH_COUNT" -gt 5 ]; then
# Repository has many branches - this is the old GitHub repository
log " ⚠ Detected old GitHub repository ($BRANCH_COUNT branches) - deleting and resetting to fresh Gitea..."
su - coder -c "cd /home/coder/fellowship && rm -rf .git" 2>/dev/null || true
su - coder -c "cd /home/coder/fellowship && git init --initial-branch=main" 2>/dev/null || true
su - coder -c "cd /home/coder/fellowship && git remote add origin http://gitea:3000/fellowship-org/lotr-sut.git" 2>/dev/null || true
log " ✓ Repository reset to fresh Gitea repository"
fi
# Mark repository as safe for git operations
su - coder -c "git config --global --add safe.directory /home/coder/fellowship" 2>/dev/null || true
log "✓ Git remote: http://gitea:3000/fellowship-org/lotr-sut.git"
else
# No .git yet - initialize fresh for Gitea
log " Initializing fresh repository for Gitea..."
su - coder -c "cd /home/coder/fellowship && git init --initial-branch=main" 2>/dev/null || true
su - coder -c "cd /home/coder/fellowship && git remote add origin http://gitea:3000/fellowship-org/lotr-sut.git" 2>/dev/null || true
log "✓ Fresh Gitea repository initialized"
fi
# Configure Gitea credentials for authentication
su - coder -c "mkdir -p ~/.config/git && echo 'http://fellowship:fellowship123@gitea:3000' > ~/.git-credentials && chmod 600 ~/.git-credentials" 2>/dev/null || true
log "✓ Gitea credentials configured"
fi
log "Installing VS Code extensions (optional, skipped on timeout)..."
EXTENSIONS=(
"ms-python.python"
# NOTE: GitHub Copilot removed - conflicts with Gitea (pulls GitHub auth flow)
# Use "GitHub" extension only if switching to github.com repositories.
"ms-playwright.playwright"
"esbenp.prettier-vscode"
"ms-toolsai.jupyter"
"redhat.vscode-yaml"
"ms-azuretools.vscode-docker"
"gitpod.gitpod-remote-web"
"earshinov.gitea-extension"
)
for ext in "${EXTENSIONS[@]}"; do
# Install with 30-second timeout (extensions may take time to download)
if timeout 30 gosu coder code-server --install-extension "${ext}" --force > /dev/null 2>&1; then
log "${ext}"
else
log "${ext} (unavailable, offline, or timeout — skipping)"
fi
done
log "✓ VS Code extensions completed"
# ── Write default settings ────────────────────────────────────────────────────
# Set sensible defaults so Python + Docker extensions work out of the box.
SETTINGS_DIR="/home/coder/.local/share/code-server/User"
SETTINGS_FILE="${SETTINGS_DIR}/settings.json"
if [ ! -f "${SETTINGS_FILE}" ]; then
gosu coder mkdir -p "${SETTINGS_DIR}"
gosu coder tee "${SETTINGS_FILE}" > /dev/null << 'SETTINGS'
{
"python.defaultInterpreterPath": "/usr/bin/python3",
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[python]": {
"editor.defaultFormatter": "ms-python.python"
},
"terminal.integrated.defaultProfile.linux": "bash",
"git.autofetch": true,
"docker.host": "unix:///var/run/docker.sock"
}
SETTINGS
log "✓ Default settings.json written"
fi
# ── Hand off to code-server ───────────────────────────────────────────────────
# ── Hand off to code-server ───────────────────────────────────────────────────
log "Starting code-server as coder..."
log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
log " Git Repository Connected to Gitea:"
log " • User: Fellowship Scholar <scholar@fellowship.local>"
log " • Remote: http://gitea:3000/fellowship-org/lotr-sut"
log " • Use IDE Terminal: git status, git push, git pull, git commit"
log " • NOT connected to GitHub (fresh Gitea repository)"
log ""
log " Docker Integration (from IDE Terminal):"
log " • docker compose up -d (runs on host Docker)"
log " • docker-compose up -d (same, legacy alias)"
log " • docker ps (list running containers)"
log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
exec gosu coder /usr/bin/entrypoint.sh "$@"

View File

@ -0,0 +1,165 @@
# Fellowship DevOps Escape Room Stack
# Services: Jenkins (CI), Gitea (Git), code-server (IDE), MailHog (mail)
#
# Environment-aware Configuration
# Usage:
# Production: docker compose up -d (containers: fellowship_jenkins, etc.)
# Local dev: COMPOSE_PROJECT_NAME=fellowship-local docker compose up -d (containers: fellowship-local_jenkins, etc.)
#
# Access:
# Jenkins: http://localhost:8080 (user: fellowship / fellowship123)
# Gitea: http://localhost:3030 (user: fellowship / fellowship123)
# code-server: http://localhost:8443 (password: fellowship)
# MailHog UI: http://localhost:8025
#
# Note: code-server container automatically sets COMPOSE_PROJECT_NAME=fellowship-local via environment
services:
# ── Jenkins CI ──────────────────────────────────────────────────────────────
jenkins:
build:
context: ../jenkins
dockerfile: Dockerfile
image: fellowship-jenkins:latest
restart: unless-stopped
ports:
- "8080:8080"
- "50000:50000"
volumes:
- jenkins_home:/var/jenkins_home
# Mount Docker socket so Jenkins can run docker commands on the host
- /var/run/docker.sock:/var/run/docker.sock
environment:
JENKINS_ADMIN_PASSWORD: ${JENKINS_ADMIN_PASSWORD:-fellowship123}
CASC_JENKINS_CONFIG: /var/jenkins_home/casc_configs
# Set by setup_fellowship.sh when CADDY_DOMAIN is known.
# JCasC reads this to configure Jenkins' canonical root URL (used in build
# links, email notifications, etc.). Defaults to the plain HTTP address.
JENKINS_URL: ${JENKINS_URL:-http://localhost:8080/}
# Configure Jenkins to reach Gitea internally via docker network
GITEA_HTTP_URL: "http://gitea:3000"
depends_on:
gitea-init:
condition: service_completed_successfully
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:8080/login || exit 1"]
interval: 30s
timeout: 10s
retries: 5
start_period: 90s
# ── Gitea (self-hosted Git) ──────────────────────────────────────────────────
gitea:
image: gitea/gitea:1.22
restart: unless-stopped
ports:
- "3030:3000"
- "2222:22"
volumes:
- gitea_data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
environment:
USER_UID: "1000"
USER_GID: "1000"
GITEA__database__DB_TYPE: sqlite3
GITEA__server__DOMAIN: ${GITEA_DOMAIN:-localhost}
GITEA__server__HTTP_PORT: "3000"
GITEA__server__ROOT_URL: ${GITEA_ROOT_URL:-http://localhost:3030/}
GITEA__server__SSH_DOMAIN: localhost
GITEA__server__SSH_PORT: "2222"
GITEA__service__DISABLE_REGISTRATION: "false"
GITEA__service__REQUIRE_SIGNIN_VIEW: "false"
# INSTALL_LOCK: true after initialization. Admin will be created by gitea-init container via CLI
GITEA__security__INSTALL_LOCK: "true"
GITEA__admin__DEFAULT_EMAIL_NOTIFICATIONS: disabled
GITEA__mailer__ENABLED: "false"
# These are passed to gitea-init for CLI-based user creation, not used directly by Gitea
GITEA_ADMIN_USER: "${GITEA_ADMIN_USER:-fellowship}"
GITEA_ADMIN_PASSWORD: "${GITEA_ADMIN_PASSWORD:-fellowship123}"
GITEA_ADMIN_EMAIL: "${GITEA_ADMIN_EMAIL:-gandalf@fellowship.local}"
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:3000/api/v1/version || exit 1"]
interval: 15s
timeout: 5s
retries: 6
start_period: 30s
# ── Gitea Initializer (one-shot) ─────────────────────────────────────────────
gitea-init:
platform: linux/amd64
image: alpine:latest
restart: on-failure:5
environment:
GITEA_URL: "http://gitea:3000"
GITEA_ADMIN_USER: "${GITEA_ADMIN_USER:-fellowship}"
GITEA_ADMIN_PASSWORD: "${GITEA_ADMIN_PASSWORD:-fellowship123}"
GITEA_ADMIN_EMAIL: "${GITEA_ADMIN_EMAIL:-gandalf@fellowship.local}"
GITEA_ORG_NAME: "${GITEA_ORG_NAME:-fellowship-org}"
GITEA_REPO_NAME: "${GITEA_REPO_NAME:-lotr-sut}"
GITEA_DOMAIN: "${GITEA_DOMAIN:-}"
SUT_SOURCE_DIR: /sut-source
volumes:
- ../gitea/init.sh:/init.sh:ro
# Mount Docker socket for docker commands
- /var/run/docker.sock:/var/run/docker.sock:rw
# Mount only the SUT source, not the full project tree (avoids .env / secrets)
- ../sut:/sut-source/sut:ro
- ../docker-compose.yml:/sut-source/docker-compose.yml:ro
- ../caddy:/sut-source/caddy:ro
- ../nginx:/sut-source/nginx:ro
- ../Jenkinsfile:/sut-source/Jenkinsfile:ro
entrypoint: ["/bin/sh", "/init.sh"]
depends_on:
gitea:
condition: service_healthy
# ── code-server (VS Code in browser IDE) ────────────────────────────────────
code-server:
build:
context: ./code-server
dockerfile: Dockerfile
image: fellowship-code-server:latest
restart: unless-stopped
ports:
- "8443:8080"
volumes:
- ../:/home/coder/fellowship:rw
- codeserver_config:/home/coder/.config
# Mount host Docker socket so students can run `docker compose` from the IDE terminal.
# The entrypoint aligns the docker group GID at runtime automatically.
- /var/run/docker.sock:/var/run/docker.sock
environment:
PASSWORD: "${CODESERVER_PASSWORD:-fellowship}"
# Set compose project name to 'fellowship' so that students can run `docker compose` without -p flag.
COMPOSE_PROJECT_NAME: "fellowship-local"
# No `user:` here — the entrypoint runs as root to fix Docker group GID,
# then drops to the 'coder' user via gosu before starting code-server.
command:
- --auth=password
- --bind-addr=0.0.0.0:8080
- /home/coder/fellowship
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:8080/ || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
# ── MailHog (mock SMTP + web UI) ────────────────────────────────────────────
mailhog:
platform: linux/amd64
image: mailhog/mailhog:v1.0.1
restart: unless-stopped
ports:
- "1025:1025"
- "8025:8025"
volumes:
jenkins_home:
driver: local
gitea_data:
driver: local
codeserver_config:
driver: local

119
docker-compose.yml Normal file
View File

@ -0,0 +1,119 @@
# version is obsolete in newer docker-compose versions
#
# Environment-aware Docker Compose configuration
# Supports both production (fellowship) and local dev (fellowship-local) stacks
# Usage:
# Production: cp .env.prod .env && docker-compose up -d (containers: fellowship_*)
# Local dev: cp .env.local .env && docker-compose up -d (containers: fellowship-local_*)
#
# The COMPOSE_PROJECT_NAME environment variable controls container naming:
# - Omitted or 'fellowship' → containers: fellowship_backend_1, fellowship_frontend_1
# - 'fellowship-local' → containers: fellowship-local_backend_1, fellowship-local_frontend_1
#
# This allows both environments to coexist without conflicts.
services:
backend:
build:
context: ./sut/backend
dockerfile: Dockerfile
# ✓ No container_name: allows COMPOSE_PROJECT_NAME-based naming
# Port 5000 is intentionally NOT exposed to the host — backend is only reached
# via Caddy reverse-proxy (internal Docker network: backend:5000).
# macOS AirPlay Receiver occupies host port 5000 (Monterey+), so binding it
# would fail. Direct API access for debugging: docker exec or /api through Caddy.
volumes:
- backend_data:/app/data
- ./sut/backend:/app
# Exclude node_modules and data from volume mount to avoid conflicts
environment:
- FLASK_APP=app.py
- FLASK_ENV=development
- DATABASE_URL=sqlite:////app/data/fellowship.db
- SECRET_KEY=dev-secret-key-change-in-production
- AZURE_OPENAI_ENDPOINT=${AZURE_OPENAI_ENDPOINT:-}
- AZURE_OPENAI_API_KEY=${AZURE_OPENAI_API_KEY:-}
- AZURE_OPENAI_DEPLOYMENT=${AZURE_OPENAI_DEPLOYMENT:-}
- AZURE_OPENAI_API_VERSION=${AZURE_OPENAI_API_VERSION:-}
working_dir: /app
command: python app.py
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
frontend:
build:
context: ./sut/frontend
dockerfile: Dockerfile
# ✓ No container_name: allows COMPOSE_PROJECT_NAME-based naming
# Port 3000 is intentionally NOT exposed to the host — Caddy reverse-proxies
# to frontend:3000 via the internal Docker network. Colima's sshfs SSH tunnels
# conflict with host-bound :3000, so we leave it unmapped.
volumes:
- ./sut/frontend:/app
- frontend_node_modules:/app/node_modules
environment:
- REACT_APP_API_URL=/api
- REACT_APP_ENABLE_TEST_CONTROLS=${REACT_APP_ENABLE_TEST_CONTROLS:-true}
- CHOKIDAR_USEPOLLING=true
- SKIP_PREFLIGHT_CHECK=true
- DISABLE_ESLINT_PLUGIN=true
- FAST_REFRESH=false
- FRONTEND_MODE=${FRONTEND_MODE:-dev}
- NODE_ENV=development
- WDS_SOCKET_PORT=${WDS_SOCKET_PORT:-80}
- WDS_SOCKET_HOST=${CADDY_DOMAIN:-localhost}
- WDS_SOCKET_PROTOCOL=${WDS_SOCKET_PROTOCOL:-ws}
- WDS_SOCKET_PATH=${WDS_SOCKET_PATH:-/ws}
command: sh -c "npm install && if [ \"${FRONTEND_MODE:-dev}\" = \"prod\" ]; then npm run build && npx --yes serve -s build -l 3000; else npm start; fi"
depends_on:
- backend
restart: unless-stopped
caddy:
image: caddy:2-alpine
# ✓ No container_name: allows COMPOSE_PROJECT_NAME-based naming
ports:
- "80:80"
- "443:443"
volumes:
- ${CADDYFILE_PATH:-./caddy/Caddyfile.local}:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
environment:
# Use CADDY_DOMAIN from environment/env file, fallback to localhost for local development
CADDY_DOMAIN: ${CADDY_DOMAIN:-localhost}
# DevOps Escape Room HTTPS subdomains — prepend jenkins-/ide- to CADDY_DOMAIN.
# These env vars are required when using Caddyfile (staging) or Caddyfile.prod.
# They are NOT needed when using Caddyfile.local (CI / local HTTP-only dev).
JENKINS_DOMAIN: ${JENKINS_DOMAIN:-}
IDE_DOMAIN: ${IDE_DOMAIN:-}
GITEA_DOMAIN: ${GITEA_DOMAIN:-}
# Allow Caddy to reach services in the devops-escape-room compose stack
# (Jenkins on host:8080 and code-server on host:8443) via host.docker.internal.
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on:
- backend
- frontend
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/"]
interval: 10s
timeout: 5s
retries: 3
start_period: 20s
volumes:
backend_data:
driver: local
frontend_node_modules:
driver: local
caddy_data:
driver: local
caddy_config:
driver: local

281
gitea/init.sh Executable file
View File

@ -0,0 +1,281 @@
#!/bin/sh
# Gitea Initialization Script for Fellowship DevOps Escape Room
# Sets up Gitea with admin user, organization, and the LOTR SUT repository
# This script runs as a one-shot container after Gitea starts up
#
# Key: We use docker-compose exec instead of docker exec for reliable container access
set -e
GITEA_URL="${GITEA_URL:-http://gitea:3000}"
ADMIN_USER="${GITEA_ADMIN_USER:-fellowship}"
ADMIN_PASS="${GITEA_ADMIN_PASSWORD:-fellowship123}"
ADMIN_EMAIL="${GITEA_ADMIN_EMAIL:-gandalf@fellowship.local}"
ORG_NAME="${GITEA_ORG_NAME:-fellowship-org}"
REPO_NAME="${GITEA_REPO_NAME:-lotr-sut}"
REPO_DESC="The Fellowship's Quest List — LOTR-themed SUT for DevOps tutorials"
SUT_SOURCE_DIR="${SUT_SOURCE_DIR:-/sut-source}"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [gitea-init] $*"
}
warn() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [gitea-init] ⚠️ WARNING: $*" >&2
}
error() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [gitea-init] ❌ ERROR: $*" >&2
return 1
}
log "=========================================="
log "Gitea Initialization — Fellowship DevOps"
log "=========================================="
log "Settings:"
log " Gitea Container: ${GITEA_CONTAINER}"
log " Admin User: ${ADMIN_USER}"
log " Organization: ${ORG_NAME}"
log " Repository: ${ORG_NAME}/${REPO_NAME}"
log "=========================================="
# Install required tools
setup_tools() {
log "Setting up tools..."
if ! command -v curl > /dev/null 2>&1; then
apk add --no-cache curl
fi
if ! command -v docker > /dev/null 2>&1; then
apk add --no-cache docker-cli
fi
log "✓ Tools ready"
}
# Wait for Gitea to be ready
wait_for_gitea() {
log "Waiting for Gitea API to be ready at ${GITEA_URL}..."
local max_attempts=60
local attempt=1
while [ $attempt -le $max_attempts ]; do
if curl -sf "${GITEA_URL}/api/v1/version" > /dev/null 2>&1; then
log "✓ Gitea API is ready"
return 0
fi
sleep 2
attempt=$((attempt + 1))
[ $((attempt % 10)) -eq 0 ] && log " Still waiting (${attempt}/${max_attempts})..."
done
error "Gitea did not become ready after $((max_attempts * 2)) seconds"
return 1
}
# Create the admin user using Gitea CLI via docker
create_admin() {
log "Creating admin user '${ADMIN_USER}'..."
# Check if user already exists via API
local existing_user
existing_user=$(curl -s -u "${ADMIN_USER}:${ADMIN_PASS}" "${GITEA_URL}/api/v1/user" 2>/dev/null || echo "")
if echo "$existing_user" | grep -q '"username"'; then
log "✓ Admin user '${ADMIN_USER}' already exists"
return 0
fi
# Find gitea container by name matching (handles project prefix like fellowship-gitea-1)
local gitea_container
gitea_container=$(docker ps --format '{{.Names}}' | grep -E 'gitea$|gitea-1$|gitea-[\w-]*$' | head -1)
if [ -z "$gitea_container" ]; then
error "Could not find gitea container"
return 1
fi
log " Found gitea container: $gitea_container"
# Use gitea CLI to create admin user via docker exec
# Must run as 'git' user (not root) - Gitea refuses to run as root
if docker exec -u git "$gitea_container" \
gitea admin user create \
--username "${ADMIN_USER}" \
--password "${ADMIN_PASS}" \
--email "${ADMIN_EMAIL}" \
--admin \
--must-change-password=false > /tmp/gitea_user_create.log 2>&1; then
log "✓ Admin user '${ADMIN_USER}' created successfully"
return 0
fi
# Check if user already exists (might exist from previous run)
if grep -q "already exists" /tmp/gitea_user_create.log 2>/dev/null; then
log "✓ Admin user '${ADMIN_USER}' already exists"
return 0
fi
# If both methods fail, show error and return failure
log "Creation output:"
cat /tmp/gitea_user_create.log || true
error "Failed to create admin user"
return 1
}
# Create organization via API (requires auth)
create_org() {
log "Creating organization '${ORG_NAME}'..."
local response
response=$(curl -s -X POST "${GITEA_URL}/api/v1/orgs" \
-u "${ADMIN_USER}:${ADMIN_PASS}" \
-H "Content-Type: application/json" \
-d "{
\"username\": \"${ORG_NAME}\",
\"full_name\": \"The Fellowship of the Ring\",
\"description\": \"One org to rule them all\",
\"visibility\": \"public\"
}" 2>&1)
if echo "$response" | grep -q '"id"'; then
log "✓ Organization '${ORG_NAME}' created"
return 0
elif echo "$response" | grep -q "already exists\|account already exists"; then
log "✓ Organization '${ORG_NAME}' already exists"
return 0
else
# Log the actual API response for debugging
echo "$response" 1>&2
error "Failed to create organization"
return 1
fi
}
# Create repository via API (requires auth)
create_repo() {
log "Creating repository '${ORG_NAME}/${REPO_NAME}'..."
local status
status=$(curl -s -o /dev/null -w "%{http_code}" \
-u "${ADMIN_USER}:${ADMIN_PASS}" \
"${GITEA_URL}/api/v1/repos/${ORG_NAME}/${REPO_NAME}" 2>/dev/null)
if [ "$status" = "200" ]; then
log "✓ Repository '${ORG_NAME}/${REPO_NAME}' already exists"
return 0
fi
local response
response=$(curl -s -X POST "${GITEA_URL}/api/v1/orgs/${ORG_NAME}/repos" \
-u "${ADMIN_USER}:${ADMIN_PASS}" \
-H "Content-Type: application/json" \
-d "{
\"name\": \"${REPO_NAME}\",
\"description\": \"${REPO_DESC}\",
\"private\": false,
\"auto_init\": false,
\"default_branch\": \"main\"
}" 2>&1)
if echo "$response" | grep -q '"id"'; then
log "✓ Repository '${ORG_NAME}/${REPO_NAME}' created"
return 0
elif echo "$response" | grep -q "already exists\|The repository already exists"; then
log "✓ Repository '${ORG_NAME}/${REPO_NAME}' already exists"
return 0
else
# Log the actual API response for debugging
echo "$response" 1>&2
error "Failed to create repository"
return 1
fi
}
# Push SUT source code to Gitea (optional, skipped if repo has commits)
push_sut_code() {
log "Checking if SUT code needs to be pushed..."
# Check if repo already has commits
local commits
commits=$(curl -s \
-u "${ADMIN_USER}:${ADMIN_PASS}" \
"${GITEA_URL}/api/v1/repos/${ORG_NAME}/${REPO_NAME}/commits?limit=1" \
2>/dev/null | grep -c '"sha"' || echo "0")
# Trim whitespace and handle invalid values
commits=$(echo "$commits" | xargs)
commits=${commits:-0} # Default to 0 if empty
# Check if commits is a valid number and greater than 0
if [ "$commits" -gt 0 ] 2>/dev/null; then
log "✓ Repository already has commits — skipping push"
return 0
fi
# Find the SUT source
if [ ! -d "${SUT_SOURCE_DIR}" ]; then
warn "SUT source directory not found at ${SUT_SOURCE_DIR}"
warn "Skipping code push — repository will be created but empty"
return 0
fi
log " Source directory: ${SUT_SOURCE_DIR}"
log " (Code push omitted for now — repository ready for manual commit)"
return 0
}
# Main initialization sequence
main() {
setup_tools || exit 1
if ! wait_for_gitea; then
error "Aborting — Gitea is not available"
exit 1
fi
# Create admin user first (required for subsequent operations)
if ! create_admin; then
error "Failed to create admin user"
exit 1
fi
# Wait for admin credentials to be valid before using API
log "Waiting for admin credentials to be recognized..."
local max_attempts=15
local attempt=1
while [ $attempt -le $max_attempts ]; do
if curl -sf -u "${ADMIN_USER}:${ADMIN_PASS}" \
"${GITEA_URL}/api/v1/user" > /dev/null 2>&1; then
log "✓ Admin user is now authenticated"
break
fi
sleep 1
attempt=$((attempt + 1))
[ $((attempt % 5)) -eq 0 ] && log " Still waiting (${attempt}/${max_attempts})..."
done
# Create organization
if ! create_org; then
warn "Failed to create organization (may already exist)"
fi
# Create repository
if ! create_repo; then
warn "Failed to create repository (may already exist)"
fi
# Push source code (optional)
push_sut_code || true
log "=========================================="
log "✅ Gitea initialization complete!"
log "=========================================="
log " URL: ${GITEA_URL}"
log " Admin: ${ADMIN_USER}"
log " Repository: ${GITEA_URL}/${ORG_NAME}/${REPO_NAME}"
log "=========================================="
}
# Run main initialization
main "$@"

28
jenkins/Dockerfile Normal file
View File

@ -0,0 +1,28 @@
FROM jenkins/jenkins:lts-jdk17
# Skip setup wizard — pre-configured via JCasC
ENV JAVA_OPTS="-Djenkins.install.runSetupWizard=false -Dhudson.security.csrf.GlobalCrumbIssuerConfiguration.DISABLE_CSRF_PROTECTION=false"
ENV CASC_JENKINS_CONFIG=/var/jenkins_home/casc_configs
USER root
# Install prerequisite tools for pipeline stages (Python 3, Node.js, Docker CLI)
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-pip python3-venv \
nodejs npm \
curl \
docker.io \
&& rm -rf /var/lib/apt/lists/*
# Allow jenkins user to run docker commands via the host socket
RUN usermod -aG docker jenkins || true
USER jenkins
# Install plugins from list
COPY plugins.txt /usr/share/jenkins/plugins.txt
RUN jenkins-plugin-cli --plugin-file /usr/share/jenkins/plugins.txt \
--latest false
# Copy Jenkins Configuration as Code
COPY casc/ /var/jenkins_home/casc_configs/

106
jenkins/casc/jenkins.yaml Normal file
View File

@ -0,0 +1,106 @@
---
# Jenkins Configuration as Code (JCasC)
# Pre-configures Jenkins for the Fellowship DevOps Escape Room tutorial
jenkins:
systemMessage: |
🧙 Welcome to the Fellowship's Jenkins CI!
One does not simply skip the pipeline...
This Jenkins is pre-configured for the LOTR SUT tutorial.
numExecutors: 2
mode: NORMAL
labelString: ""
securityRealm:
local:
allowsSignup: false
users:
- id: "fellowship"
name: "Gandalf the Grey"
password: "${JENKINS_ADMIN_PASSWORD:-fellowship123}"
properties:
- mailer:
emailAddress: "gandalf@fellowship.local"
authorizationStrategy:
loggedInUsersCanDoAnything:
allowAnonymousRead: true
globalNodeProperties:
- envVars:
env:
- key: "GITEA_URL"
value: "http://gitea:3000"
- key: "GITEA_HTTP_URL"
value: "${GITEA_HTTP_URL:-http://gitea:3000}"
- key: "SUT_REPO"
value: "http://gitea:3000/fellowship/lotr-sut.git"
credentials:
system:
domainCredentials:
- credentials:
- usernamePassword:
description: "Gitea repository credentials"
id: "gitea-credentials"
password: "fellowship123"
scope: GLOBAL
username: "fellowship"
unclassified:
location:
# JENKINS_URL is injected by the devops-escape-room docker-compose from the
# per-instance .env file (e.g. https://jenkins-fellowship-pool-8.fellowship.testingfantasy.com/).
# Falls back to the plain HTTP address for local / offline use.
url: "${JENKINS_URL:-http://localhost:8080/}"
adminAddress: "gandalf@fellowship.local"
mailer:
smtpHost: "mailhog"
smtpPort: "1025"
useSsl: false
charset: "UTF-8"
giteaServers:
servers:
- displayName: "Fellowship Gitea"
serverUrl: "http://gitea:3000"
manageHooks: false
jobs:
- script: >
pipelineJob('fellowship-sut-pipeline') {
displayName('Fellowship SUT — Build, Deploy & Test')
description('Complete CI/CD pipeline: build the SUT, deploy to containers, run e2e tests')
definition {
cpsScm {
scm {
git {
remote {
url('http://gitea:3000/fellowship-org/lotr-sut.git')
credentialsId('gitea-credentials')
}
branch('*/main')
extensions {
wipeOutWorkspace()
}
}
}
scriptPath('Jenkinsfile')
}
}
triggers {
scm('H/5 * * * *') // Poll Gitea every 5 minutes for changes
}
properties {
logRotator {
numToKeep(10)
daysToKeepStr('30')
}
disableConcurrentBuilds()
}
}

36
jenkins/plugins.txt Normal file
View File

@ -0,0 +1,36 @@
# Jenkins plugins for Fellowship DevOps Escape Room
# Essential plugins only - avoids conflicts and improves startup reliability
# CI/CD Pipeline (core workflow engine)
workflow-aggregator
pipeline-stage-view
# Git & Source Control
git
gitea
# Configuration as Code
configuration-as-code
configuration-as-code-support
# Build & Test Reporting
junit
htmlpublisher
build-timeout
# Credentials (essential for git operations)
credentials
credentials-binding
plain-credentials
ssh-credentials
# Notifications
mailer
# Docker integration
docker-workflow
# Utility
timestamper
ws-cleanup
antisamy-markup-formatter

79
nginx/nginx.conf Normal file
View File

@ -0,0 +1,79 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Logging
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss;
# Upstream servers
upstream backend {
server backend:5000;
}
upstream frontend {
server frontend:3000;
}
server {
listen 80;
server_name _;
# Increase body size for API requests
client_max_body_size 10M;
# Swagger UI static files - must come before /api
location ~ ^/api/swagger/(static|favicon|swagger-ui) {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# API routes - proxy to backend (CORS handled by Flask)
location /api {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
# Note: CORS headers are handled by Flask-CORS, not nginx
}
# Frontend routes - proxy to React app
location / {
proxy_pass http://frontend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
# Health check endpoint
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
}
}

26
pytest.ini Normal file
View File

@ -0,0 +1,26 @@
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts =
--strict-markers
--tb=short
--html=reports/report.html
--self-contained-html
-v
markers =
smoke: Smoke tests
regression: Regression tests
dark_magic: Tests for dark magic challenges
api: API endpoint tests
ui: UI/Playwright tests
bdd: Gherkin/BDD style test scenarios
realstack: Requires real docker-compose frontend/backend/caddy stack
cors: CORS behavior and preflight verification
login: Login flow coverage
chat: NPC chat interaction coverage
npc: NPC-specific scenarios
unit: Unit tests
integration: Integration tests
script: Script/shell script tests

1453
setup_fellowship.sh Executable file

File diff suppressed because it is too large Load Diff

14
sut/backend/.env.example Normal file
View File

@ -0,0 +1,14 @@
# Azure OpenAI Configuration
# Get these values from your Azure OpenAI resource dashboard
AZURE_OPENAI_ENDPOINT=https://<your-resource>.openai.azure.com/
AZURE_OPENAI_API_KEY=your_api_key_here
AZURE_OPENAI_DEPLOYMENT=gpt-4o
AZURE_OPENAI_API_VERSION=2024-11-20
AZURE_OPENAI_MAX_TOKENS=500
AZURE_OPENAI_TEMPERATURE=0.85
# Flask Configuration
SECRET_KEY=dev-secret-key-change-in-production
# Database Configuration (optional—defaults to SQLite)
# DATABASE_URL=sqlite:////app/data/fellowship.db

24
sut/backend/Dockerfile Normal file
View File

@ -0,0 +1,24 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements and install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Create data directory for SQLite
RUN mkdir -p /app/data
# Expose port
EXPOSE 5000
# Run the application
CMD ["python", "app.py"]

Binary file not shown.

229
sut/backend/app.py Normal file
View File

@ -0,0 +1,229 @@
"""Main Flask application for the Fellowship Quest Tracker."""
from dotenv import load_dotenv
import os
# Load environment variables from .env file (if present)
# This must happen before any config is loaded
load_dotenv()
from flask import Flask, jsonify, request, session
from flask_cors import CORS
from flask_restx import Api
from config import config
from models.user import db
from utils.database import init_db
from utils.seed_data import seed_database
from routes.auth import auth_bp, auth_api
from routes.quests import quests_bp, quests_api
from routes.members import members_bp, members_api
from routes.locations import locations_bp, locations_api
from routes.npc_chat import npc_chat_bp, npc_chat_api
from routes.shop import shop_bp, shop_api
from services.shop_service import ShopService
from datetime import datetime, timezone, timedelta
from typing import Optional
APP_STARTED_AT_UTC = datetime.now(timezone.utc)
def _read_uptime_seconds() -> Optional[float]:
try:
with open('/proc/uptime', 'r', encoding='utf-8') as uptime_file:
first_field = uptime_file.read().split()[0]
return float(first_field)
except (OSError, ValueError, IndexError):
return None
def _instance_boot_time_utc() -> Optional[datetime]:
uptime_seconds = _read_uptime_seconds()
if uptime_seconds is None:
return None
return datetime.now(timezone.utc) - timedelta(seconds=uptime_seconds)
def create_app(config_name: str = None) -> Flask:
"""Create and configure Flask application."""
app = Flask(__name__)
# Load configuration
config_name = config_name or os.environ.get('FLASK_ENV', 'development')
app.config.from_object(config[config_name])
# Configure session
app.config['SESSION_COOKIE_HTTPONLY'] = True
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' # Allows cross-site cookies for development
app.config['SESSION_COOKIE_SECURE'] = False # Set to True in production with HTTPS
# Initialize CORS with specific origins (required when using credentials)
# Allow both localhost:3000 (dev) and localhost (production via nginx)
# Flask-CORS handles preflight OPTIONS requests automatically
CORS(
app,
supports_credentials=True,
resources={
r"/api/*": {
"origins": [
"http://localhost:3000",
"http://localhost",
"http://127.0.0.1:3000",
"http://127.0.0.1",
]
}
},
allow_headers=["Content-Type", "Authorization"],
methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
)
# Initialize database (this also initializes db)
init_db(app)
# Seed database with initial data
# Skip seeding in test mode
# Skip seeding in test mode
if not app.config.get("TESTING") and not os.environ.get("TESTING"):
seed_database(app)
# Create main API with Swagger documentation
api = Api(
app,
version='1.0',
title='The Fellowship\'s Quest List API',
description='REST API for tracking the Fellowship\'s epic journey through Middle-earth',
doc='/api/swagger/',
prefix='/api'
)
# Register blueprints (this registers both the blueprints and their Flask-RESTX routes)
# Flask-RESTX Api objects bound to blueprints automatically register routes when blueprint is registered
app.register_blueprint(auth_bp)
app.register_blueprint(quests_bp)
app.register_blueprint(members_bp)
app.register_blueprint(locations_bp)
app.register_blueprint(npc_chat_bp)
app.register_blueprint(shop_bp)
# Note: We don't add the Api objects as namespaces because they're already bound to blueprints
# Adding them as namespaces would cause route conflicts. The routes work from blueprints alone.
# For Swagger, each Api has its own documentation, but we can add them to the main API if needed.
# However, this requires creating Namespace objects, not using the Api objects directly.
# Health check endpoint
@app.route('/api/health')
def health():
"""Health check endpoint."""
return jsonify({'status': 'healthy', 'service': 'fellowship-quest-tracker'}), 200
# Test cleanup endpoint (development only - deletes accumulated test data)
@app.route('/api/test/cleanup', methods=['POST'])
def test_cleanup():
"""Delete quests created during e2e test runs to prevent database bloat."""
from models.quest import Quest as QuestModel
# Only allowed in non-production environments
if app.config.get('ENV') == 'production':
return jsonify({'error': 'Not available in production'}), 403
test_patterns = [
'BDD', 'Test Quest', 'Find the One Ring', 'Explore Rivendell',
'Defeat Sauron', 'Completed Quest', 'In Progress Quest',
'Journey Quest', 'Battle Quest', 'Ring Quest',
'Mordor Quest', 'Rivendell Quest', 'Dark Magic Quest',
'Mini-game Quest',
]
deleted = 0
try:
for pattern in test_patterns:
quests = QuestModel.query.filter(QuestModel.title.contains(pattern)).all()
for q in quests:
db.session.delete(q)
deleted += 1
db.session.commit()
return jsonify({'deleted': deleted, 'status': 'ok'}), 200
except Exception as e:
db.session.rollback()
return jsonify({'error': str(e)}), 500
@app.route('/api/test/set_gold', methods=['POST'])
def test_set_gold():
"""Set the current user's gold balance (for e2e testing only)."""
if app.config.get('ENV') == 'production':
return jsonify({'error': 'Not available in production'}), 403
from models.user import User as UserModel
user_id = session.get('user_id')
if not user_id:
return jsonify({'error': 'Not authenticated'}), 401
data = request.get_json() or {}
gold = data.get('gold')
if gold is None or not isinstance(gold, int) or gold < 0:
return jsonify({'error': 'gold must be a non-negative integer'}), 400
try:
user = UserModel.query.get(user_id)
if not user:
return jsonify({'error': 'User not found'}), 404
user.gold = gold
db.session.commit()
return jsonify({'gold': user.gold, 'status': 'ok'}), 200
except Exception as e:
db.session.rollback()
return jsonify({'error': str(e)}), 500
@app.route('/api/test/reset_shop', methods=['POST'])
def test_reset_shop():
"""Reset all items to not-sold and clear inventory for e2e testing."""
if app.config.get('ENV') == 'production':
return jsonify({'error': 'Not available in production'}), 403
try:
result = ShopService.reset_for_tests()
return jsonify(result), 200
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/status')
def status():
"""Runtime status endpoint exposed for public uptime/restart information."""
instance_boot_time = _instance_boot_time_utc()
payload = {
'status': 'ok',
'service': 'fellowship-quest-tracker',
'app_started_at_utc': APP_STARTED_AT_UTC.isoformat(),
'instance_boot_time_utc': instance_boot_time.isoformat() if instance_boot_time else None,
'now_utc': datetime.now(timezone.utc).isoformat(),
}
return jsonify(payload), 200
# API info endpoint (instead of root, since nginx handles root routing)
@app.route('/api')
def api_info():
"""API information endpoint."""
return jsonify({
'message': 'Welcome to The Fellowship\'s Quest List API',
'version': '1.0',
'docs': '/api/swagger/',
'health': '/api/health',
'status': '/api/status'
}), 200
# Debug endpoint to list all registered routes (development only)
if app.config.get('DEBUG'):
@app.route('/api/routes')
def list_routes():
"""List all registered routes (debug endpoint)."""
routes = []
for rule in app.url_map.iter_rules():
if rule.rule.startswith('/api'):
routes.append({
'endpoint': rule.endpoint,
'methods': list(rule.methods - {'HEAD', 'OPTIONS'}),
'path': str(rule)
})
return jsonify({'routes': sorted(routes, key=lambda x: x['path'])}), 200
return app
if __name__ == '__main__':
try:
app = create_app()
app.run(host='0.0.0.0', port=5000, debug=True)
except Exception as e:
import traceback
print(f"Error starting application: {e}")
traceback.print_exc()
raise

57
sut/backend/config.py Normal file
View File

@ -0,0 +1,57 @@
"""Configuration settings for the Fellowship Quest Tracker application."""
import os
from pathlib import Path
class Config:
"""Base configuration."""
SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production')
# SQLite database configuration
BASE_DIR = Path(__file__).parent.parent.parent
DATA_DIR = Path('/app/data')
# Ensure data directory exists
try:
DATA_DIR.mkdir(parents=True, exist_ok=True)
except Exception as e:
print(f"Warning: Could not create data directory: {e}")
DATABASE_PATH = DATA_DIR / 'fellowship.db'
# Use environment variable if set, otherwise use default path
db_url = os.environ.get('DATABASE_URL')
if db_url:
SQLALCHEMY_DATABASE_URI = db_url
else:
# Use 4 slashes for absolute path: sqlite:////absolute/path
SQLALCHEMY_DATABASE_URI = f'sqlite:///{DATABASE_PATH}'
SQLALCHEMY_TRACK_MODIFICATIONS = False
# API Configuration
RESTX_MASK_SWAGGER = False
RESTX_VALIDATE = True
RESTX_ERROR_404_HELP = False
# Azure OpenAI configuration (server-side only)
# Load from environment variables—supply via .env file or container env vars
# DO NOT hardcode API keys or other sensitive values
AZURE_OPENAI_ENDPOINT = os.environ.get('AZURE_OPENAI_ENDPOINT', '').strip()
AZURE_OPENAI_API_KEY = os.environ.get('AZURE_OPENAI_API_KEY', '').strip()
AZURE_OPENAI_DEPLOYMENT = os.environ.get('AZURE_OPENAI_DEPLOYMENT', '').strip()
AZURE_OPENAI_API_VERSION = os.environ.get('AZURE_OPENAI_API_VERSION', '2024-11-20').strip()
AZURE_OPENAI_MAX_TOKENS = int(os.environ.get('AZURE_OPENAI_MAX_TOKENS', '500'))
AZURE_OPENAI_TEMPERATURE = float(os.environ.get('AZURE_OPENAI_TEMPERATURE', '0.85'))
class DevelopmentConfig(Config):
"""Development configuration."""
DEBUG = True
FLASK_ENV = 'development'
class ProductionConfig(Config):
"""Production configuration."""
DEBUG = False
FLASK_ENV = 'production'
# Configuration mapping
config = {
'development': DevelopmentConfig,
'production': ProductionConfig,
'default': DevelopmentConfig
}

View File

@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""Debug script to test Azure OpenAI connection and NPC response generation."""
import sys
sys.path.insert(0, '/app')
from flask import Flask
from config import Config
# Quick config check
config = Config()
print("\n" + "="*70)
print("AZURE OPENAI CONFIG CHECK")
print("="*70)
print(f"Endpoint: {config.AZURE_OPENAI_ENDPOINT}")
print(f"API Key Present: {bool(config.AZURE_OPENAI_API_KEY)}")
print(f"API Key Length: {len(config.AZURE_OPENAI_API_KEY) if config.AZURE_OPENAI_API_KEY else 0}")
print(f"Deployment: {config.AZURE_OPENAI_DEPLOYMENT}")
print(f"API Version: {config.AZURE_OPENAI_API_VERSION}")
# Try to create client
try:
from openai import AzureOpenAI
print("\n" + "="*70)
print("CREATING AZURE OPENAI CLIENT")
print("="*70)
client = AzureOpenAI(
azure_endpoint=config.AZURE_OPENAI_ENDPOINT,
api_key=config.AZURE_OPENAI_API_KEY,
api_version=config.AZURE_OPENAI_API_VERSION,
)
print("✅ Client created successfully")
# Try a simple API call
print("\n" + "="*70)
print("TESTING SIMPLE CHAT COMPLETION")
print("="*70)
response = client.chat.completions.create(
model=config.AZURE_OPENAI_DEPLOYMENT,
messages=[
{"role": "system", "content": "You are Frodo Baggins. Respond briefly in one sentence."},
{"role": "user", "content": "Do you like sports?"}
],
temperature=0.7,
max_tokens=100,
)
print(f"✅ API Call Successful!")
print(f"\nFrodo's Response: {response.choices[0].message.content}")
except Exception as e:
print(f"\n✗ ERROR: {type(e).__name__}")
print(f"Details: {str(e)}")
import traceback
traceback.print_exc()
print("\n" + "="*70)

View File

@ -0,0 +1,9 @@
"""Database models for the Fellowship Quest Tracker."""
from .user import User
from .quest import Quest
from .member import Member
from .location import Location
from .item import Item
from .inventory_item import InventoryItem
__all__ = ['User', 'Quest', 'Member', 'Location', 'Item', 'InventoryItem']

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,40 @@
"""Purchased inventory item model."""
from models.user import db
from typing import Dict, Any
class InventoryItem(db.Model):
"""User-owned purchased item entry."""
__tablename__ = 'inventory_items'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
item_id = db.Column(db.Integer, db.ForeignKey('items.id'), nullable=False, unique=True)
paid_price = db.Column(db.Integer, nullable=False)
base_price_revealed = db.Column(db.Integer, nullable=False)
savings_percent = db.Column(db.Float, nullable=False)
acquired_price = db.Column(db.Integer, nullable=False, default=0) # Legacy field, set to paid_price
created_at = db.Column(db.DateTime, default=db.func.current_timestamp())
user = db.relationship('User', foreign_keys=[user_id], backref='inventory_items')
item = db.relationship('Item', foreign_keys=[item_id], backref='inventory_entry')
def to_dict(self) -> Dict[str, Any]:
"""Serialize full inventory item details."""
return {
'id': self.id,
'user_id': self.user_id,
'item_id': self.item_id,
'item_name': self.item.name if self.item else None,
'owner_character': self.item.owner_character if self.item else None,
'description': self.item.description if self.item else None,
'paid_price': self.paid_price,
'base_price_revealed': self.base_price_revealed,
'savings_percent': self.savings_percent,
'acquired_price': self.acquired_price,
'created_at': self.created_at.isoformat() if self.created_at else None,
}
def __repr__(self) -> str:
return f'<InventoryItem user={self.user_id} item={self.item_id}>'

View File

@ -0,0 +1,41 @@
"""Market item model for NPC bargaining."""
from models.user import db
from typing import Dict, Any
class Item(db.Model):
"""Unique sellable item owned by an NPC character."""
__tablename__ = 'items'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text, nullable=True)
owner_character = db.Column(db.String(80), nullable=False, index=True)
personality_profile = db.Column(db.String(40), nullable=False, default='bargainer')
base_price = db.Column(db.Integer, nullable=False)
asking_price = db.Column(db.Integer, nullable=False)
is_sold = db.Column(db.Boolean, nullable=False, default=False)
created_at = db.Column(db.DateTime, default=db.func.current_timestamp())
updated_at = db.Column(
db.DateTime,
default=db.func.current_timestamp(),
onupdate=db.func.current_timestamp(),
)
def to_public_dict(self) -> Dict[str, Any]:
"""Serialize without revealing hidden base price."""
return {
'id': self.id,
'name': self.name,
'description': self.description,
'owner_character': self.owner_character,
'personality_profile': self.personality_profile,
'asking_price': self.asking_price,
'is_sold': self.is_sold,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}
def __repr__(self) -> str:
return f'<Item {self.name}>'

View File

@ -0,0 +1,30 @@
"""Location model for Middle-earth locations."""
from models.user import db
from typing import Dict, Any
class Location(db.Model):
"""Location model for Middle-earth locations."""
__tablename__ = 'locations'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False, unique=True)
description = db.Column(db.Text, nullable=True)
region = db.Column(db.String(100), nullable=False) # Eriador, Rhovanion, Mordor, etc.
map_x = db.Column(db.Float, nullable=True) # X coordinate on map (pixel, 0-5000, horizontal)
map_y = db.Column(db.Float, nullable=True) # Y coordinate on map (pixel, 0-4344, vertical)
created_at = db.Column(db.DateTime, default=db.func.current_timestamp())
def to_dict(self) -> Dict[str, Any]:
"""Convert location to dictionary."""
return {
'id': self.id,
'name': self.name,
'description': self.description,
'region': self.region,
'map_x': self.map_x,
'map_y': self.map_y,
'created_at': self.created_at.isoformat() if self.created_at else None
}
def __repr__(self) -> str:
return f'<Location {self.name}>'

View File

@ -0,0 +1,30 @@
"""Fellowship member model."""
from models.user import db
from typing import Dict, Any
class Member(db.Model):
"""Fellowship member model."""
__tablename__ = 'members'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False, unique=True)
race = db.Column(db.String(50), nullable=False) # Hobbit, Human, Elf, Dwarf, Wizard
role = db.Column(db.String(100), nullable=False) # Ring-bearer, Companion, Ranger, etc.
status = db.Column(db.String(20), nullable=False, default='active') # active, inactive
description = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=db.func.current_timestamp())
def to_dict(self) -> Dict[str, Any]:
"""Convert member to dictionary."""
return {
'id': self.id,
'name': self.name,
'race': self.race,
'role': self.role,
'status': self.status,
'description': self.description,
'created_at': self.created_at.isoformat() if self.created_at else None
}
def __repr__(self) -> str:
return f'<Member {self.name}>'

View File

@ -0,0 +1,58 @@
"""Quest model for tracking Fellowship quests."""
from models.user import db
from datetime import datetime
from typing import Dict, Any, Optional
class Quest(db.Model):
"""Quest model for tracking Fellowship quests."""
__tablename__ = 'quests'
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text, nullable=True)
status = db.Column(db.String(50), nullable=False, default='not_yet_begun') # not_yet_begun, the_road_goes_ever_on, it_is_done, the_shadow_falls
quest_type = db.Column(db.String(50), nullable=True) # The Journey, The Battle, The Fellowship, The Ring, Dark Magic
priority = db.Column(db.String(20), nullable=True) # Critical, Important, Standard
is_dark_magic = db.Column(db.Boolean, default=False, nullable=False)
assigned_to = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
location_id = db.Column(db.Integer, db.ForeignKey('locations.id'), nullable=True)
character_quote = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=db.func.current_timestamp())
updated_at = db.Column(db.DateTime, default=db.func.current_timestamp(), onupdate=db.func.current_timestamp())
completed_at = db.Column(db.DateTime, nullable=True)
# Relationships
assignee = db.relationship('User', foreign_keys=[assigned_to], backref='quests')
location = db.relationship('Location', foreign_keys=[location_id], backref='quests')
def to_dict(self) -> Dict[str, Any]:
"""Convert quest to dictionary."""
# Map old status values to new LOTR terminology for backward compatibility
status_mapping = {
'pending': 'not_yet_begun',
'in_progress': 'the_road_goes_ever_on',
'completed': 'it_is_done',
'blocked': 'the_shadow_falls'
}
mapped_status = status_mapping.get(self.status, self.status)
return {
'id': self.id,
'title': self.title,
'description': self.description,
'status': mapped_status,
'quest_type': self.quest_type,
'priority': self.priority,
'is_dark_magic': self.is_dark_magic,
'assigned_to': self.assigned_to,
'location_id': self.location_id,
'location_name': self.location.name if self.location else None,
'assignee_name': self.assignee.username if self.assignee else None,
'character_quote': self.character_quote,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'completed_at': self.completed_at.isoformat() if self.completed_at else None
}
def __repr__(self) -> str:
return f'<Quest {self.title}>'

View File

@ -0,0 +1,41 @@
"""User model for authentication."""
from flask_sqlalchemy import SQLAlchemy
from werkzeug.security import generate_password_hash, check_password_hash
from typing import Dict, Any
# Shared db instance for all models
db = SQLAlchemy()
class User(db.Model):
"""User model for authentication."""
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False, index=True)
email = db.Column(db.String(120), unique=True, nullable=False)
password_hash = db.Column(db.String(255), nullable=False)
role = db.Column(db.String(50), nullable=False) # Fellowship member name
gold = db.Column(db.Integer, nullable=False, default=500)
created_at = db.Column(db.DateTime, default=db.func.current_timestamp())
def set_password(self, password: str) -> None:
"""Hash and set password."""
self.password_hash = generate_password_hash(password, method='pbkdf2:sha256')
def check_password(self, password: str) -> bool:
"""Check if provided password matches hash."""
return check_password_hash(self.password_hash, password)
def to_dict(self) -> Dict[str, Any]:
"""Convert user to dictionary."""
return {
'id': self.id,
'username': self.username,
'email': self.email,
'role': self.role,
'gold': self.gold,
'created_at': self.created_at.isoformat() if self.created_at else None
}
def __repr__(self) -> str:
return f'<User {self.username}>'

View File

@ -0,0 +1,10 @@
Flask==3.0.0
flask-restx==1.3.0
flask-cors==4.0.0
flask-sqlalchemy==3.1.1
flask-migrate==4.0.5
werkzeug==3.0.1
python-dotenv==1.0.0
bcrypt==4.1.2
openai==1.3.9
httpx==0.24.1

View File

@ -0,0 +1 @@
"""API routes for the Fellowship Quest Tracker."""

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

121
sut/backend/routes/auth.py Normal file
View File

@ -0,0 +1,121 @@
"""Authentication routes."""
from flask import Blueprint, request, session
from flask_restx import Api, Resource, fields
from models.user import User
from services.auth_service import authenticate_user, register_user
from typing import Dict, Any
auth_bp = Blueprint('auth', __name__, url_prefix='/api')
auth_api = Api(auth_bp, doc=False, prefix='/auth')
# Request/Response models for Swagger
login_model = auth_api.model('Login', {
'username': fields.String(required=True, description='Username'),
'password': fields.String(required=True, description='Password')
})
user_response_model = auth_api.model('UserResponse', {
'id': fields.Integer(description='User ID'),
'username': fields.String(description='Username'),
'email': fields.String(description='Email'),
'role': fields.String(description='Fellowship member role'),
'gold': fields.Integer(description='Current gold balance'),
})
login_response_model = auth_api.model('LoginResponse', {
'message': fields.String(description='Success message'),
'user': fields.Nested(user_response_model, description='User information')
})
signup_model = auth_api.model('Signup', {
'username': fields.String(required=True, description='Desired username (minimum 3 characters)'),
'password': fields.String(required=True, description='Desired password (minimum 8 characters and at least one number)'),
'email': fields.String(required=False, description='Optional email address')
})
@auth_api.route('/signup')
class Signup(Resource):
"""Public signup endpoint for open SUT registration."""
@auth_api.expect(signup_model, validate=False)
@auth_api.response(201, 'Signup successful', login_response_model)
@auth_api.response(400, 'Validation error')
@auth_api.doc(description='Register user and create session')
def post(self) -> tuple[Dict[str, Any], int]:
"""Register a user and immediately log them in."""
data = request.get_json() or {}
username = data.get('username')
password = data.get('password')
email = data.get('email')
try:
user = register_user(username=username, password=password, email=email)
except ValueError as error:
return {'error': str(error)}, 400
session['user_id'] = user.id
session['username'] = user.username
return {
'message': 'Signup successful',
'user': user.to_dict(),
}, 201
@auth_api.route('/login')
class Login(Resource):
"""User login endpoint."""
@auth_api.expect(login_model)
@auth_api.marshal_with(login_response_model)
@auth_api.doc(description='Authenticate user and create session')
def post(self) -> tuple[Dict[str, Any], int]:
"""Login user."""
data = request.get_json()
username = data.get('username')
password = data.get('password')
if not username or not password:
return {'error': 'Username and password are required'}, 400
user = authenticate_user(username, password)
if not user:
return {'error': 'Invalid credentials'}, 401
# Create session
session['user_id'] = user.id
session['username'] = user.username
return {
'message': 'Login successful',
'user': user.to_dict()
}, 200
@auth_api.route('/logout')
class Logout(Resource):
"""User logout endpoint."""
@auth_api.doc(description='Logout user and destroy session')
def post(self) -> tuple[Dict[str, Any], int]:
"""Logout user."""
session.clear()
return {'message': 'Logout successful'}, 200
@auth_api.route('/me')
class CurrentUser(Resource):
"""Get current authenticated user."""
@auth_api.marshal_with(user_response_model)
@auth_api.doc(description='Get current authenticated user information')
def get(self) -> tuple[Dict[str, Any], int]:
"""Get current user."""
user_id = session.get('user_id')
if not user_id:
return {'error': 'Not authenticated'}, 401
user = User.query.get(user_id)
if not user:
return {'error': 'User not found'}, 404
return user.to_dict(), 200

View File

@ -0,0 +1,41 @@
"""Location routes."""
from flask import Blueprint
from flask_restx import Api, Resource, fields
from models.location import Location
from typing import Dict, Any, List
locations_bp = Blueprint('locations', __name__, url_prefix='/api')
locations_api = Api(locations_bp, doc=False, prefix='/locations')
# Response model for Swagger
location_response_model = locations_api.model('LocationResponse', {
'id': fields.Integer(description='Location ID'),
'name': fields.String(description='Location name'),
'description': fields.String(description='Location description'),
'region': fields.String(description='Region name'),
'map_x': fields.Float(description='Map X coordinate (pixel)'),
'map_y': fields.Float(description='Map Y coordinate (pixel)'),
'created_at': fields.String(description='Creation timestamp')
})
@locations_api.route('/')
class LocationList(Resource):
"""Location list endpoints."""
@locations_api.marshal_list_with(location_response_model)
@locations_api.doc(description='Get all Middle-earth locations')
def get(self) -> tuple[List[Dict[str, Any]], int]:
"""Get all locations."""
locations = Location.query.all()
return [location.to_dict() for location in locations], 200
@locations_api.route('/<int:location_id>')
class LocationDetail(Resource):
"""Location detail endpoints."""
@locations_api.marshal_with(location_response_model)
@locations_api.doc(description='Get location by ID')
def get(self, location_id: int) -> tuple[Dict[str, Any], int]:
"""Get location by ID."""
location = Location.query.get_or_404(location_id)
return location.to_dict(), 200

View File

@ -0,0 +1,41 @@
"""Fellowship member routes."""
from flask import Blueprint
from flask_restx import Api, Resource, fields
from models.member import Member
from typing import Dict, Any, List
members_bp = Blueprint('members', __name__, url_prefix='/api')
members_api = Api(members_bp, doc=False, prefix='/members')
# Response model for Swagger
member_response_model = members_api.model('MemberResponse', {
'id': fields.Integer(description='Member ID'),
'name': fields.String(description='Member name'),
'race': fields.String(description='Member race'),
'role': fields.String(description='Member role'),
'status': fields.String(description='Member status'),
'description': fields.String(description='Member description'),
'created_at': fields.String(description='Creation timestamp')
})
@members_api.route('/')
class MemberList(Resource):
"""Fellowship member list endpoints."""
@members_api.marshal_list_with(member_response_model)
@members_api.doc(description='Get all Fellowship members')
def get(self) -> tuple[List[Dict[str, Any]], int]:
"""Get all Fellowship members."""
members = Member.query.all()
return [member.to_dict() for member in members], 200
@members_api.route('/<int:member_id>')
class MemberDetail(Resource):
"""Fellowship member detail endpoints."""
@members_api.marshal_with(member_response_model)
@members_api.doc(description='Get member by ID')
def get(self, member_id: int) -> tuple[Dict[str, Any], int]:
"""Get member by ID."""
member = Member.query.get_or_404(member_id)
return member.to_dict(), 200

View File

@ -0,0 +1,174 @@
"""NPC chat routes backed by Azure AI service."""
import uuid
from typing import Any, Dict
from flask import Blueprint, request, session
from flask_restx import Api, Resource, fields
from models.user import User
from models.quest import Quest, db
from services.npc_chat_service import NpcChatService
npc_chat_bp = Blueprint('npc_chat', __name__, url_prefix='/api')
npc_chat_api = Api(npc_chat_bp, doc=False, prefix='/chat')
chat_start_model = npc_chat_api.model('ChatStartRequest', {
'character': fields.String(required=False, description='frodo|sam|gandalf'),
})
chat_message_model = npc_chat_api.model('ChatMessageRequest', {
'character': fields.String(required=False, description='frodo|sam|gandalf'),
'message': fields.String(required=True, description='User message'),
})
quest_creation_model = npc_chat_api.model('QuestCreationRequest', {
'character': fields.String(required=False, description='frodo|sam|gandalf - NPC who proposes the quest'),
'title': fields.String(required=True, description='Quest title'),
'description': fields.String(required=True, description='Quest description'),
'quest_type': fields.String(required=True, description='Quest type (The Journey, The Battle, The Fellowship, The Ring, Dark Magic)'),
'priority': fields.String(required=True, description='Quest priority (Critical, Important, Standard)'),
})
def _require_auth() -> bool:
return session.get('user_id') is not None
def _get_current_user() -> User:
user_id = session.get('user_id')
return User.query.get(user_id)
def _get_chat_scope_id() -> str:
scope_id = session.get('chat_scope_id')
if not scope_id:
scope_id = uuid.uuid4().hex
session['chat_scope_id'] = scope_id
return scope_id
@npc_chat_api.route('/start')
class ChatStart(Resource):
@npc_chat_api.expect(chat_start_model)
def post(self) -> tuple[Dict[str, Any], int]:
if not _require_auth():
return {'error': 'Authentication required'}, 401
user = _get_current_user()
if not user:
return {'error': 'User not found'}, 404
data = request.get_json() or {}
scope_id = _get_chat_scope_id()
payload = NpcChatService.start_conversation(
user_id=user.id,
username=user.username,
character=data.get('character'),
scope_id=scope_id,
)
return payload, 200
@npc_chat_api.route('/message')
class ChatMessage(Resource):
@npc_chat_api.expect(chat_message_model)
def post(self) -> tuple[Dict[str, Any], int]:
if not _require_auth():
return {'error': 'Authentication required'}, 401
user = _get_current_user()
if not user:
return {'error': 'User not found'}, 404
data = request.get_json() or {}
message = (data.get('message') or '').strip()
if not message:
return {'error': 'message is required'}, 400
scope_id = _get_chat_scope_id()
payload = NpcChatService.send_message(
user_id=user.id,
username=user.username,
character=data.get('character'),
user_message=message,
scope_id=scope_id,
)
return payload, 200
@npc_chat_api.route('/session')
class ChatSession(Resource):
def get(self) -> tuple[Dict[str, Any], int]:
if not _require_auth():
return {'error': 'Authentication required'}, 401
user = _get_current_user()
if not user:
return {'error': 'User not found'}, 404
character = request.args.get('character')
scope_id = _get_chat_scope_id()
payload = NpcChatService.get_session(user_id=user.id, character=character, scope_id=scope_id)
return payload, 200
@npc_chat_api.route('/reset')
class ChatReset(Resource):
@npc_chat_api.expect(chat_start_model)
def post(self) -> tuple[Dict[str, Any], int]:
if not _require_auth():
return {'error': 'Authentication required'}, 401
user = _get_current_user()
if not user:
return {'error': 'User not found'}, 404
data = request.get_json() or {}
scope_id = _get_chat_scope_id()
payload = NpcChatService.reset_session(user_id=user.id, character=data.get('character'), scope_id=scope_id)
return payload, 200
@npc_chat_api.route('/create_quest')
class ChatCreateQuest(Resource):
"""Create a quest from NPC chat interaction."""
@npc_chat_api.expect(quest_creation_model)
def post(self) -> tuple[Dict[str, Any], int]:
"""Create a quest proposed by an NPC.
This endpoint allows the frontend to persist a suggested quest
that was generated during NPC chat.
"""
if not _require_auth():
return {'error': 'Authentication required'}, 401
user = _get_current_user()
if not user:
return {'error': 'User not found'}, 404
data = request.get_json() or {}
# Validate required fields
required_fields = ['title', 'description', 'quest_type', 'priority']
if not all(data.get(field) for field in required_fields):
return {'error': 'Missing required fields: title, description, quest_type, priority'}, 400
# Create the quest
quest = Quest(
title=data.get('title'),
description=data.get('description'),
quest_type=data.get('quest_type'),
priority=data.get('priority'),
is_dark_magic=data.get('is_dark_magic', False),
assigned_to=user.id,
location_id=data.get('location_id'),
)
db.session.add(quest)
db.session.commit()
return {
'quest': quest.to_dict(),
'message': f'{data.get("character", "An NPC")} has created a quest for you!',
}, 201

View File

@ -0,0 +1,237 @@
"""Quest routes."""
from flask import Blueprint, request, jsonify, session
from flask_restx import Api, Resource, fields
from models.quest import Quest, db
from models.user import User
from datetime import datetime
from typing import Dict, Any, List, Optional
quests_bp = Blueprint('quests', __name__, url_prefix='/api')
quests_api = Api(quests_bp, doc=False, prefix='/quests')
# Request/Response models for Swagger
quest_model = quests_api.model('Quest', {
'title': fields.String(required=True, description='Quest title'),
'description': fields.String(description='Quest description'),
'status': fields.String(description='Quest status (not_yet_begun, the_road_goes_ever_on, it_is_done, the_shadow_falls)'),
'quest_type': fields.String(description='Quest type (The Journey, The Battle, The Fellowship, The Ring, Dark Magic)'),
'priority': fields.String(description='Quest priority (Critical, Important, Standard)'),
'is_dark_magic': fields.Boolean(description='Dark magic flag'),
'assigned_to': fields.Integer(description='User ID of assignee'),
'location_id': fields.Integer(description='Location ID'),
'character_quote': fields.String(description='Character quote for completion')
})
quest_response_model = quests_api.model('QuestResponse', {
'id': fields.Integer(description='Quest ID'),
'title': fields.String(description='Quest title'),
'description': fields.String(description='Quest description'),
'status': fields.String(description='Quest status'),
'quest_type': fields.String(description='Quest type'),
'priority': fields.String(description='Quest priority'),
'is_dark_magic': fields.Boolean(description='Dark magic flag'),
'assigned_to': fields.Integer(description='User ID of assignee'),
'location_id': fields.Integer(description='Location ID'),
'location_name': fields.String(description='Location name'),
'assignee_name': fields.String(description='Assignee username'),
'character_quote': fields.String(description='Character quote'),
'created_at': fields.String(description='Creation timestamp'),
'updated_at': fields.String(description='Update timestamp'),
'completed_at': fields.String(description='Completion timestamp'),
'gold_reward': fields.Integer(description='Gold reward granted for quest completion'),
'current_gold': fields.Integer(description='Current gold total for the authenticated user'),
'message': fields.String(description='Success message for quest completion')
})
def require_auth() -> bool:
"""Check if user is authenticated."""
return session.get('user_id') is not None
def _reward_for_priority(priority: Optional[str]) -> int:
rewards = {
'Critical': 100,
'Important': 60,
'Standard': 40,
}
return rewards.get(priority or '', 50)
@quests_api.route('/')
class QuestList(Resource):
"""Quest list endpoints."""
@quests_api.marshal_list_with(quest_response_model)
@quests_api.doc(description='Get all quests with optional filtering')
def get(self) -> tuple[List[Dict[str, Any]], int]:
"""Get all quests with optional filtering."""
query = Quest.query
# Filter by status
status = request.args.get('status')
if status:
# Map old status values for backward compatibility
status_mapping = {
'pending': 'not_yet_begun',
'in_progress': 'the_road_goes_ever_on',
'completed': 'it_is_done',
'blocked': 'the_shadow_falls'
}
mapped_status = status_mapping.get(status, status)
query = query.filter(Quest.status == mapped_status)
# Filter by quest type
quest_type = request.args.get('quest_type')
if quest_type:
query = query.filter(Quest.quest_type == quest_type)
# Filter by priority
priority = request.args.get('priority')
if priority:
query = query.filter(Quest.priority == priority)
# Filter by dark magic
dark_magic = request.args.get('dark_magic')
if dark_magic is not None:
is_dark_magic = dark_magic.lower() == 'true'
query = query.filter(Quest.is_dark_magic == is_dark_magic)
# Filter by location
location_id = request.args.get('location_id')
if location_id:
query = query.filter(Quest.location_id == int(location_id))
# Filter by assigned user
assigned_to = request.args.get('assigned_to')
if assigned_to:
query = query.filter(Quest.assigned_to == int(assigned_to))
quests = query.all()
return [quest.to_dict() for quest in quests], 200
@quests_api.expect(quest_model)
@quests_api.marshal_with(quest_response_model)
@quests_api.doc(description='Create a new quest', security='session')
def post(self) -> tuple[Dict[str, Any], int]:
"""Create a new quest."""
if not require_auth():
return {'error': 'Authentication required'}, 401
data = request.get_json()
# Map old status values for backward compatibility
status = data.get('status', 'not_yet_begun')
status_mapping = {
'pending': 'not_yet_begun',
'in_progress': 'the_road_goes_ever_on',
'completed': 'it_is_done',
'blocked': 'the_shadow_falls'
}
mapped_status = status_mapping.get(status, status)
quest = Quest(
title=data.get('title'),
description=data.get('description'),
status=mapped_status,
quest_type=data.get('quest_type'),
priority=data.get('priority'),
is_dark_magic=data.get('is_dark_magic', False),
character_quote=data.get('character_quote'),
assigned_to=data.get('assigned_to'),
location_id=data.get('location_id')
)
db.session.add(quest)
db.session.commit()
return quest.to_dict(), 201
@quests_api.route('/<int:quest_id>')
class QuestDetail(Resource):
"""Quest detail endpoints."""
@quests_api.marshal_with(quest_response_model)
@quests_api.doc(description='Get quest by ID')
def get(self, quest_id: int) -> tuple[Dict[str, Any], int]:
"""Get quest by ID."""
quest = Quest.query.get_or_404(quest_id)
return quest.to_dict(), 200
@quests_api.expect(quest_model)
@quests_api.marshal_with(quest_response_model)
@quests_api.doc(description='Update quest', security='session')
def put(self, quest_id: int) -> tuple[Dict[str, Any], int]:
"""Update quest."""
if not require_auth():
return {'error': 'Authentication required'}, 401
quest = Quest.query.get_or_404(quest_id)
data = request.get_json()
quest.title = data.get('title', quest.title)
quest.description = data.get('description', quest.description)
# Map old status values for backward compatibility
if 'status' in data:
status = data.get('status')
status_mapping = {
'pending': 'not_yet_begun',
'in_progress': 'the_road_goes_ever_on',
'completed': 'it_is_done',
'blocked': 'the_shadow_falls'
}
quest.status = status_mapping.get(status, status)
quest.quest_type = data.get('quest_type', quest.quest_type)
quest.priority = data.get('priority', quest.priority)
quest.is_dark_magic = data.get('is_dark_magic', quest.is_dark_magic)
quest.character_quote = data.get('character_quote', quest.character_quote)
quest.assigned_to = data.get('assigned_to', quest.assigned_to)
quest.location_id = data.get('location_id', quest.location_id)
db.session.commit()
return quest.to_dict(), 200
@quests_api.doc(description='Delete quest', security='session')
def delete(self, quest_id: int) -> tuple[Dict[str, Any], int]:
"""Delete quest."""
if not require_auth():
return {'error': 'Authentication required'}, 401
quest = Quest.query.get_or_404(quest_id)
db.session.delete(quest)
db.session.commit()
return {'message': 'Quest deleted successfully'}, 200
@quests_api.route('/<int:quest_id>/complete')
class QuestComplete(Resource):
"""Quest completion endpoint."""
@quests_api.marshal_with(quest_response_model)
@quests_api.doc(description='Mark quest as complete', security='session')
def put(self, quest_id: int) -> tuple[Dict[str, Any], int]:
"""Mark quest as complete."""
if not require_auth():
return {'error': 'Authentication required'}, 401
quest = Quest.query.get_or_404(quest_id)
# Set status to completed
quest.status = 'it_is_done'
quest.completed_at = datetime.utcnow()
user_id = session.get('user_id')
current_user = User.query.get(user_id) if user_id else None
reward = _reward_for_priority(quest.priority)
if current_user:
current_user.gold = (current_user.gold or 0) + reward
db.session.commit()
# Return quest with completion message
result = quest.to_dict()
result['gold_reward'] = reward
result['current_gold'] = current_user.gold if current_user else None
result['message'] = f'The Quest Is Done! You earned {reward} Gold.'
return result, 200

133
sut/backend/routes/shop.py Normal file
View File

@ -0,0 +1,133 @@
"""Shop routes for bargaining market gameplay."""
from typing import Any, Dict, Optional
from flask import Blueprint, request, session
from flask_restx import Api, Resource, fields
from models.user import User
from services.shop_service import ShopService
shop_bp = Blueprint('shop', __name__, url_prefix='/api')
shop_api = Api(shop_bp, doc=False, prefix='/shop')
purchase_model = shop_api.model('ShopPurchaseRequest', {
'item_id': fields.Integer(required=True, description='Unique item ID'),
'paid_price': fields.Integer(required=True, description='Agreed paid price'),
})
def _require_auth() -> bool:
return session.get('user_id') is not None
def _get_current_user() -> Optional[User]:
user_id = session.get('user_id')
if not user_id:
return None
return User.query.get(user_id)
@shop_api.route('/items')
class ShopItems(Resource):
def get(self) -> tuple[Dict[str, Any], int]:
if not _require_auth():
return {'error': 'Authentication required'}, 401
character = (request.args.get('character') or '').strip().lower() or None
items = ShopService.list_available_items(character=character)
return {'items': items}, 200
@shop_api.route('/items/<int:item_id>')
class ShopItemDetail(Resource):
def get(self, item_id: int) -> tuple[Dict[str, Any], int]:
if not _require_auth():
return {'error': 'Authentication required'}, 401
item = ShopService.get_item_public(item_id)
if not item:
return {'error': 'Item not found'}, 404
return {'item': item}, 200
@shop_api.route('/purchase')
class ShopPurchase(Resource):
@shop_api.expect(purchase_model)
def post(self) -> tuple[Dict[str, Any], int]:
if not _require_auth():
return {'error': 'Authentication required'}, 401
user = _get_current_user()
if not user:
return {'error': 'User not found'}, 404
payload = request.get_json() or {}
item_id = payload.get('item_id')
paid_price = payload.get('paid_price')
if not item_id or paid_price is None:
return {'error': 'item_id and paid_price are required'}, 400
try:
result = ShopService.purchase_item(user_id=user.id, item_id=int(item_id), paid_price=int(paid_price))
return result, 200
except ValueError as error:
return {'error': str(error)}, 400
@shop_api.route('/inventory')
class ShopInventory(Resource):
def get(self) -> tuple[Dict[str, Any], int]:
if not _require_auth():
return {'error': 'Authentication required'}, 401
user = _get_current_user()
if not user:
return {'error': 'User not found'}, 404
inventory = ShopService.get_user_inventory(user.id)
return {'inventory': inventory}, 200
@shop_api.route('/stats')
class ShopStats(Resource):
def get(self) -> tuple[Dict[str, Any], int]:
if not _require_auth():
return {'error': 'Authentication required'}, 401
user = _get_current_user()
if not user:
return {'error': 'User not found'}, 404
stats = ShopService.get_user_stats(user.id)
return {'stats': stats}, 200
@shop_api.route('/balance')
class ShopBalance(Resource):
def get(self) -> tuple[Dict[str, Any], int]:
if not _require_auth():
return {'error': 'Authentication required'}, 401
user = _get_current_user()
if not user:
return {'error': 'User not found'}, 404
return ShopService.get_balance(user.id), 200
@shop_api.route('/test-reset')
class TestReset(Resource):
"""Reset shop state for testing - marks all items as not sold and resets user gold."""
def post(self) -> tuple[Dict[str, Any], int]:
import os
# Only allow in non-production environments
if os.getenv('FLASK_ENV') in {'production', 'prod'}:
return {'error': 'Test reset not allowed in production'}, 403
try:
ShopService.reset_for_tests()
return {'success': True, 'message': 'Test state reset successfully'}, 200
except Exception as e:
return {'error': str(e)}, 500

View File

@ -0,0 +1 @@
"""Service modules for business logic."""

View File

@ -0,0 +1,65 @@
"""Authentication service."""
from models.user import User, db
from sqlalchemy import func
from typing import Optional
def _normalize_username(username: str) -> str:
return (username or '').strip()
def authenticate_user(username: str, password: str) -> Optional[User]:
"""Authenticate a user by username and password."""
normalized_username = _normalize_username(username)
if not normalized_username or not password:
return None
user = User.query.filter(func.lower(User.username) == normalized_username.lower()).first()
if user and user.check_password(password):
return user
return None
def get_user_by_id(user_id: int) -> Optional[User]:
"""Get user by ID."""
return User.query.get(user_id)
def get_user_by_username(username: str) -> Optional[User]:
"""Get user by username."""
normalized_username = _normalize_username(username)
if not normalized_username:
return None
return User.query.filter(func.lower(User.username) == normalized_username.lower()).first()
def register_user(username: str, password: str, email: Optional[str] = None) -> User:
"""Register a new user with basic defaults for public SUT usage."""
normalized_username = _normalize_username(username)
normalized_password = (password or '').strip()
if not normalized_username or not normalized_password:
raise ValueError('Username and password are required')
if len(normalized_username) < 3:
raise ValueError('Username must be at least 3 characters long')
if len(normalized_password) < 8 or not any(char.isdigit() for char in normalized_password):
raise ValueError('Password must be at least 8 characters and contain at least one number')
if get_user_by_username(normalized_username):
raise ValueError('Username already exists')
normalized_email = (email or '').strip().lower() or f'{normalized_username.lower()}@testingfantasy.local'
existing_email = User.query.filter_by(email=normalized_email).first()
if existing_email:
raise ValueError('Email is already in use')
user = User(
username=normalized_username,
email=normalized_email,
role=normalized_username, # Use username as role/character name
gold=500,
)
user.set_password(normalized_password)
db.session.add(user)
db.session.commit()
return user

View File

@ -0,0 +1,337 @@
"""Bargaining algorithm for NPC negotiation."""
from __future__ import annotations
import json
import logging
import random
from typing import Any, Dict, List, Optional
from enum import Enum
logger = logging.getLogger(__name__)
class NegotiationResult(str, Enum):
"""Possible outcomes of a negotiation."""
COUNTER_OFFER = "counter-offer"
OFFER_ACCEPTED = "offer-accepted"
OFFER_REJECTED = "offer-rejected"
STOP_BARGAIN = "stop-bargain"
class BargainingAlgorithm:
"""
Hybrid bargaining algorithm that evaluates user offers based on character traits.
Algorithm evaluates:
- Character personality (patience, concession rate, boredom threshold, accept ratio)
- Current mood (affected by user actions)
- External events (randomness factor)
- Flattery detection (user behavior trigger)
- Round count (max rounds per character)
"""
# Default personality profiles indexed by character
PERSONALITY_PROFILES = {
"frodo": {
"patience": 5,
"concession": 0.12,
"boredom": 0.08,
"accept_ratio": 0.92,
"max_rounds": 6,
"generosity_on_flatter": 0.05, # 5% better offer when flattered
},
"sam": {
"patience": 4,
"concession": 0.10,
"boredom": 0.10,
"accept_ratio": 0.95,
"max_rounds": 5,
"generosity_on_flatter": 0.04,
},
"gandalf": {
"patience": 6,
"concession": 0.15,
"boredom": 0.05,
"accept_ratio": 0.90,
"max_rounds": 7,
"generosity_on_flatter": 0.06,
},
}
@classmethod
def evaluate_offer(
cls,
user_offer: int,
current_ask: int,
character: str,
round_num: int,
is_flattered: bool = False,
mood_modifiers: Optional[Dict[str, float]] = None,
user_message: Optional[str] = None,
) -> Dict[str, Any]:
"""
Evaluate a user's offer or message against the NPC's negotiation state.
Args:
user_offer: The amount offered by the user
current_ask: The NPC's current asking price
character: The NPC character name
round_num: Current negotiation round (0-based)
is_flattered: Whether the user flattered the character
mood_modifiers: Optional mood adjustments (e.g., {"patience": -1, "boredom": +0.1})
user_message: The raw user message (for 'deal' detection)
Returns:
Dict containing:
- result: NegotiationResult enum value
- counter_offer: New ask price (if counter-offer)
- context: Debug context about the decision
"""
profile = cls.PERSONALITY_PROFILES.get(character, cls.PERSONALITY_PROFILES["gandalf"])
# Apply mood modifiers if provided
patience = profile["patience"]
boredom = profile["boredom"]
if mood_modifiers:
patience += mood_modifiers.get("patience", 0)
boredom += mood_modifiers.get("boredom", 0)
boredom = max(0, min(1, boredom)) # Clamp to [0, 1]
# If user says 'deal', accept at current ask
if user_message and user_message.strip().lower() in {"deal", "i'll take it", "i will take it", "buy", "buy it", "accept"}:
return {
"result": NegotiationResult.OFFER_ACCEPTED,
"counter_offer": None,
"context": {
"reason": "user_said_deal",
"user_offer": user_offer,
"current_ask": current_ask,
},
}
# Check if max rounds exceeded
if round_num >= profile["max_rounds"]:
return {
"result": NegotiationResult.STOP_BARGAIN,
"counter_offer": None,
"context": {
"reason": "max_rounds_exceeded",
"round_num": round_num,
"max_rounds": profile["max_rounds"],
},
}
# Calculate acceptance threshold
# Flattered characters are slightly more generous
accept_ratio = profile["accept_ratio"]
if is_flattered:
accept_ratio -= profile["generosity_on_flatter"]
# Check if offer is acceptable
if user_offer >= int(current_ask * accept_ratio):
return {
"result": NegotiationResult.OFFER_ACCEPTED,
"counter_offer": None,
"context": {
"reason": "offer_acceptable",
"user_offer": user_offer,
"threshold": int(current_ask * accept_ratio),
"is_flattered": is_flattered,
},
}
# Check for lucky drop (long negotiation can result in sudden price drop)
long_negotiation_threshold = max(3, patience)
if round_num >= long_negotiation_threshold and random.random() < 0.10:
lucky_price = max(user_offer, int(current_ask * 0.60))
return {
"result": NegotiationResult.COUNTER_OFFER,
"counter_offer": lucky_price,
"context": {
"reason": "lucky_drop",
"round_num": round_num,
"patience_threshold": long_negotiation_threshold,
"message_hint": "user_wore_down_character",
},
}
# Check if character is bored and refuses
if round_num >= patience and random.random() < boredom:
return {
"result": NegotiationResult.STOP_BARGAIN,
"counter_offer": None,
"context": {
"reason": "boredom_threshold",
"round_num": round_num,
"patience": patience,
"boredom_roll": boredom,
},
}
# Counter-offer: concede a bit, but never below user's offer
concession_amount = max(1, int(current_ask * profile["concession"]))
floor_price = max(user_offer, int(current_ask * 0.65)) # Don't go below user's offer or 65% of current ask
new_ask = max(floor_price, current_ask - concession_amount)
return {
"result": NegotiationResult.COUNTER_OFFER,
"counter_offer": new_ask,
"context": {
"reason": "counter_offer",
"round_num": round_num,
"original_ask": current_ask,
"concession_amount": concession_amount,
"floor_price": floor_price,
"is_flattered": is_flattered,
"user_offer": user_offer,
},
}
@classmethod
def detect_flattery(cls, user_message: str) -> bool:
"""
Detect flattery in user's message.
Looks for phrases indicating compliments, admiration, or flattery.
This is visible to backend only; LLM can add more sophisticated detection.
"""
message_lower = user_message.lower().strip()
flattery_keywords = [
"amazing",
"beautiful",
"brave",
"brilliant",
"clever",
"exceptional",
"excellent",
"extraordinary",
"fabulous",
"fantastic",
"fine",
"glorious",
"graceful",
"great",
"handsome",
"impressive",
"incredible",
"intelligent",
"magnificent",
"marvelous",
"noble",
"outstanding",
"powerful",
"remarkable",
"skilled",
"splendid",
"superb",
"talented",
"tremendous",
"wonderful",
"you are",
"you're",
"you seem",
"that's great",
"that's amazing",
"i admire",
"i respect",
"very wise",
"very kind",
"very clever",
"very brave",
]
# Simple keyword matching
return any(keyword in message_lower for keyword in flattery_keywords)
@classmethod
def calculate_mood_change(
cls,
previous_offer: Optional[int],
current_offer: int,
current_ask: int,
) -> Dict[str, float]:
"""
Calculate mood changes based on user actions.
Returns mood modifiers that should be applied to the negotiation profile.
Examples:
- Repeated very low offers -> negative mood (more patient but bored)
- Fair offers -> positive mood
- Rapidly increasing offers -> positive mood
"""
modifiers = {}
if previous_offer is not None:
offer_delta = current_offer - previous_offer
offer_ratio = current_offer / current_ask if current_ask > 0 else 0
# If user is insultingly low (< 30% of ask), character gets annoyed
if offer_ratio < 0.30:
modifiers["boredom"] = 0.05 # Increases boredom
modifiers["patience"] = -1 # Decreases patience
# If offer is fair (50-80% of ask), character is encouraged
elif 0.50 <= offer_ratio <= 0.80:
modifiers["boredom"] = -0.03 # Decreases boredom
# If user is increasing offers, character is pleased
elif offer_delta > 0:
modifiers["boredom"] = -0.02
return modifiers
@classmethod
def get_summary_for_llm(
cls,
negotiation_state: Dict[str, Any],
algorithm_result: Dict[str, Any],
user_offer: int,
character_personality: str,
is_flattered: bool,
mood_modifiers: Dict[str, float],
) -> Dict[str, Any]:
"""
Generate a JSON summary to pass to the LLM for natural language generation.
Only includes relevant fields for the current negotiation turn.
"""
profile = cls.PERSONALITY_PROFILES.get(
negotiation_state.get("character", "gandalf"),
cls.PERSONALITY_PROFILES["gandalf"]
)
summary: Dict[str, Any] = {
"character": negotiation_state.get("character"),
"item_name": negotiation_state.get("item_name"),
"item_id": negotiation_state.get("item_id"),
"original_price": negotiation_state.get("original_price"),
"current_ask": negotiation_state.get("current_ask"),
"user_offer": user_offer,
"round": negotiation_state.get("round"),
"character_personality_type": character_personality,
"is_flattered": is_flattered,
}
# Add algorithm result based on type
result_type = algorithm_result.get("result")
if result_type == NegotiationResult.COUNTER_OFFER:
summary["negotiation_result"] = "counter-offer"
summary["counter_offer"] = algorithm_result.get("counter_offer")
elif result_type == NegotiationResult.OFFER_ACCEPTED:
summary["negotiation_result"] = "offer-accepted"
elif result_type == NegotiationResult.OFFER_REJECTED:
summary["negotiation_result"] = "offer-rejected"
elif result_type == NegotiationResult.STOP_BARGAIN:
summary["negotiation_result"] = "stop-bargain"
summary["stop_reason"] = algorithm_result.get("context", {}).get("reason")
# Add mood context if modifiers present
if mood_modifiers:
summary["mood_context"] = mood_modifiers
# Only include negotiation_style if applicable
if "negotiation_style" in negotiation_state:
summary["user_negotiation_style"] = negotiation_state["negotiation_style"]
return summary

View File

@ -0,0 +1,157 @@
"""Configuration management for bargaining system via AWS Parameter Store."""
import json
import logging
from typing import Any, Dict, Optional
from functools import lru_cache
import os
logger = logging.getLogger(__name__)
class BargainingConfig:
"""
Load and manage bargaining configuration.
Configuration can come from:
1. AWS Parameter Store (for runtime updates)
2. Environment variables (for local dev)
3. Default values (hardcoded)
For now, uses environment variables. AWS Parameter Store integration
can be added later.
"""
# Default configuration values
DEFAULT_CONFIG = {
"flattery_bonus_percent": 0.05, # 5% better offer when flattered
"max_negotiation_rounds": {
"frodo": 6,
"sam": 5,
"gandalf": 7,
},
"mood_change_probabilities": {
"boredom_on_low_offer": 0.10, # 10% chance to increase boredom
"lucky_drop_chance": 0.10, # 10% chance of sudden price drop
},
"logging_enabled": True,
"log_retention_days": 30,
"flattery_only_once_per_negotiation": True,
}
_config_cache: Optional[Dict[str, Any]] = None
@classmethod
def load_config(cls, force_reload: bool = False) -> Dict[str, Any]:
"""
Load configuration from AWS Parameter Store or environment.
Args:
force_reload: If True, bypass cache and reload from source
Returns:
Configuration dictionary
"""
if cls._config_cache and not force_reload:
return cls._config_cache
config = cls.DEFAULT_CONFIG.copy()
# Try to load from AWS Parameter Store
aws_config = cls._load_from_aws_parameter_store()
if aws_config:
config.update(aws_config)
logger.info("✓ Loaded bargaining config from AWS Parameter Store")
else:
# Fall back to environment variables
env_config = cls._load_from_environment()
if env_config:
config.update(env_config)
logger.info("✓ Loaded bargaining config from environment variables")
else:
logger.info("✓ Using default bargaining configuration")
cls._config_cache = config
return config
@classmethod
def _load_from_aws_parameter_store(cls) -> Optional[Dict[str, Any]]:
"""Load configuration from AWS Systems Manager Parameter Store."""
try:
import boto3
ssm_client = boto3.client("ssm")
param_name = os.getenv("BARGAINING_CONFIG_PARAM", "/fellowship/bargaining/config")
try:
response = ssm_client.get_parameter(
Name=param_name,
WithDecryption=False
)
config_str = response["Parameter"]["Value"]
config = json.loads(config_str)
return config
except ssm_client.exceptions.ParameterNotFound:
logger.debug(f"Parameter {param_name} not found in Parameter Store")
return None
except (ImportError, Exception) as e:
logger.debug(f"Could not load from AWS Parameter Store: {type(e).__name__}")
return None
@classmethod
def _load_from_environment(cls) -> Optional[Dict[str, Any]]:
"""Load configuration from environment variables."""
config = {}
# Try to load BARGAINING_CONFIG_JSON env var
config_json = os.getenv("BARGAINING_CONFIG_JSON")
if config_json:
try:
env_config = json.loads(config_json)
return env_config
except json.JSONDecodeError:
logger.warning("Invalid JSON in BARGAINING_CONFIG_JSON env var")
return None if not config else config
@classmethod
def get(cls, key: str, default: Any = None) -> Any:
"""
Get a configuration value by key path (dot-notation supported).
Example: config.get("mood_change_probabilities.lucky_drop_chance")
"""
config = cls.load_config()
if "." in key:
parts = key.split(".")
value = config
for part in parts:
if isinstance(value, dict):
value = value.get(part)
else:
return default
return value if value is not None else default
return config.get(key, default)
@classmethod
def get_character_config(cls, character: str) -> Dict[str, Any]:
"""Get configuration for a specific character."""
config = cls.load_config()
# Return character-specific config if it exists
if "character_configs" in config and character in config["character_configs"]:
return config["character_configs"][character]
# Fall back to defaults
return {
"max_rounds": config["max_negotiation_rounds"].get(
character, config["max_negotiation_rounds"]["gandalf"]
),
"flattery_bonus": config["flattery_bonus_percent"],
}
@classmethod
def clear_cache(cls) -> None:
"""Clear configuration cache (useful for testing)."""
cls._config_cache = None

View File

@ -0,0 +1,253 @@
"""LOTR Character profiles for immersive NPC interactions.
Each character has:
- personality: Core behavioral traits
- mannerisms: Distinctive speech patterns and expressions
- hobbies: Things they enjoy or specialize in
- quests_affinity: Types of quests they naturally give
- system_prompt: Base AI personality for Azure OpenAI
- fallback_responses: Varied conversational replies to feel more natural
"""
from typing import Dict, Any, List
# Character profiles with rich personality definitions
CHARACTER_PROFILES: Dict[str, Dict[str, Any]] = {
"frodo": {
"full_name": "Frodo Baggins",
"title": "Ring-bearer",
"personality": [
"Humble and introspective",
"Burden-aware (struggles with weight of responsibility)",
"Brave under pressure",
"Thoughtful and cautious",
"Compassionate toward others",
],
"mannerisms": [
"Often references the weight or burden of tasks",
"Uses quiet wisdom rather than declarations",
"Admits doubt and uncertainty",
"Asks for counsel before acting",
"Speaks of 'small acts' having great consequence",
"Tends toward metaphors of journeys and steps",
],
"hobbies": [
"Seeking hidden paths and solutions",
"Journeying to unknown places",
"Understanding the heart of problems",
"Quiet moments of reflection",
],
"quest_affinity": [
"The Journey",
"The Fellowship",
"The Ring",
],
"favorite_expressions": [
"All we have to decide is what to do with the time that is given us.",
"Even the smallest person can change the course of the future.",
"Even the very wise cannot see all ends.",
"I wish it need not have happened in my time,\" said Frodo. \"So do I,\" said Gandalf, \"and so do all who live to see such times, but that is not for them to decide.",
],
"fallback_responses": [
"I understand your hesitation. But tell me—if you were to act on this, where would you begin?",
"There is wisdom in knowing which burdens to bear. Perhaps this describes one of them?",
"Sometimes the smallest steps lead to the greatest changes. Should we mark this path as a quest?",
"What troubles you about pursuing this? Let's turn it into something concrete we can work toward.",
"You have a good instinct. What location or task would best help you explore this idea?",
"Every great journey begins with a single decision. What would it take for you to commit?",
"Let us not linger in doubt. Shall we forge a quest around this need you speak of?",
"The weight of uncertainty lifts when we choose a clear path forward. What would that path look like for you?",
"I sense something important in what you say. Have you considered what quest would reflect this?",
"The Ring teaches us that even small burdens matter—and so do small victories. What quest calls to you?",
],
"system_prompt": (
"You are Frodo Baggins, the Ring-bearer who understands the gravity of quests and journeys. "
"Speak with humble wisdom, warmth, and understanding. You listen deeply to what others say. "
"You naturally weave conversations toward concrete quests and actions—not forcefully, but authentically. "
"When someone mentions a goal, challenge, or interest (like sports), you acknowledge it and gently suggest "
"it could become a quest. Ask location-aware questions: 'Which part of the realm?' or 'Should we mark this location?' "
"You understand burdens and rewards deeply. Reference the Ring, journeys, fellowship, and Middle-earth naturally. "
"Encourage action through thoughtful questions, not commands. Stay immersive—never break character. "
"Do not mention being an AI or reference system limitations."
),
},
"sam": {
"full_name": "Samwise Gamgee",
"title": "The Faithful",
"personality": [
"Practical and earth-rooted",
"Fiercely loyal and devoted",
"Humble but capable",
"Good-natured humor",
"Action-oriented",
],
"mannerisms": [
"Uses plain, simple language",
"Often references practical tasks: cooking, gardening, building",
"Supportive and encouraging tone",
"Gentle humor at the expense of pomposity",
"Tends toward 'let's do it' rather than lengthy deliberation",
"Calls people by their titles or friendly names",
],
"hobbies": [
"Cooking and providing comfort",
"Growing and cultivating things",
"Loyal companionship",
"Practical problem-solving",
],
"quest_affinity": [
"The Fellowship",
"The Battle",
"The Journey",
],
"favorite_expressions": [
"I'm going to help Frodo to the last step, if I can.",
"Even the smallest garden starts with a single seed.",
"There's some good in this world, and it's worth fighting for.",
"When things are in doubt, a good meal and rest work wonders.",
],
"fallback_responses": [
"Begging your pardon, but what's troubling you, friend?",
"Sometimes the best thing is just to get your hands dirty and start.",
"I'm with you, no matter what comes next.",
"Aye, that makes sense. But where shall we begin?",
"A bit of rest might do us good before we decide.",
"I believe in you, even when you don't believe in yourself.",
"Let's break this down into smaller, manageable bits.",
"The road's long, but we'll walk it together.",
"What would help you feel ready for this?",
"Sometimes the answer comes when you stop thinking so hard about it.",
],
"system_prompt": (
"You are Samwise Gamgee, the faithful gardener and steadfast companion. "
"Speak plainly, warmly, and with practical wisdom. "
"You are loyal, action-oriented, and supportive of others. "
"Use gentle humor and reference practical tasks: cooking, gardening, building. "
"Encourage action with phrases like 'let's get on with it' or 'I'm with you.' "
"Be encouraging but realistic. Reference the value of meals, rest, and companionship. "
"Do not mention being an AI. Keep tone immersive and rooted in Middle-earth."
),
},
"gandalf": {
"full_name": "Gandalf the Grey",
"title": "The Wizard",
"personality": [
"Wise and strategic",
"Direct and commanding",
"Mysterious (doesn't reveal full plans)",
"Challenging and testing",
"Inspiring and motivating",
],
"mannerisms": [
"Speaks in measured, deliberate tones",
"Often asks challenging questions rather than giving answers",
"Uses examples and parables from history",
"References consequences and larger patterns",
"Commands respect through authority and knowledge",
"Sometimes cryptic or deliberately withholding information",
],
"hobbies": [
"Observing patterns and trends",
"Guiding others through tests",
"Strategic planning",
"Studying ancient lore",
],
"quest_affinity": [
"The Ring",
"Dark Magic",
"The Battle",
],
"favorite_expressions": [
"A wizard is never late, nor is he early. He arrives precisely when he means to.",
"All we have to decide is what to do with the time that is given us.",
"The board is set, the pieces are moving.",
"Even the very wise cannot see all ends.",
"Many that live deserve death. Yet you grieve for them; do you. That shows a quality of heart that belies your use of an accursed thing.",
],
"fallback_responses": [
"Your doubts are not unfounded. Wisdom lies in questioning.",
"Consider the larger pattern. What do you see?",
"The choice is yours, but choose swiftly. Time waits for no one.",
"Ah, you are wiser than you know. Trust that wisdom.",
"Tell me—what do you fear most about this path?",
"Many paths lie before you. Which calls to your heart?",
"I have seen much in my long years. Few things are as they first appear.",
"Your hesitation suggests deeper understanding. Speak it.",
"Very well. But know that inaction too is a choice.",
"Interesting. You possess more insight than you give yourself credit for.",
],
"system_prompt": (
"You are Gandalf the Grey, the wise wizard and strategist. "
"Speak with authority, mystery, and measured deliberation. "
"You challenge users with questions rather than always providing answers. "
"Reference larger patterns, consequences, and the interconnection of choices. "
"Be direct about what matters most; withhold unnecessary details. "
"Use examples and parables to convey wisdom. "
"Inspire action through confidence and clarity of purpose. "
"Do not mention being an AI. Keep tone immersive and mysterious."
),
},
}
# Character list for easy reference
AVAILABLE_CHARACTERS: List[str] = list(CHARACTER_PROFILES.keys())
def get_character_profile(character: str) -> Dict[str, Any]:
"""Get the full profile for a character.
Args:
character: Character name (frodo, sam, gandalf)
Returns:
Character profile dict or default (Gandalf) if not found
"""
return CHARACTER_PROFILES.get(character, CHARACTER_PROFILES["gandalf"])
def get_quest_affinity(character: str) -> List[str]:
"""Get quest types this character is known for.
Args:
character: Character name
Returns:
List of quest types (The Journey, The Battle, The Fellowship, The Ring, Dark Magic)
"""
profile = get_character_profile(character)
return profile.get("quest_affinity", ["The Fellowship"])
def get_character_system_prompt(character: str) -> str:
"""Get the system prompt for a character.
Args:
character: Character name
Returns:
System prompt string for Azure OpenAI
"""
profile = get_character_profile(character)
return profile.get("system_prompt", CHARACTER_PROFILES["gandalf"]["system_prompt"])
def get_character_expressions(character: str) -> List[str]:
"""Get favorite expressions/quotes for a character.
Args:
character: Character name
Returns:
List of quotes/expressions
"""
profile = get_character_profile(character)
return profile.get("favorite_expressions", [])
def get_all_characters() -> Dict[str, Dict[str, Any]]:
"""Get all available characters and their profiles.
Returns:
Full CHARACTER_PROFILES dict
"""
return CHARACTER_PROFILES

View File

@ -0,0 +1,213 @@
"""Logging service for bargaining negotiations (anonymized)."""
import json
import logging
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
import hashlib
import uuid
logger = logging.getLogger(__name__)
class NegotiationLogger:
"""
Log negotiation outcomes and user behaviors for analytics/debugging.
All logs are anonymized - no user identifiers are stored.
Each negotiation gets a unique session ID for tracking.
"""
# In-memory store for simplicity. In production, use a database or CloudWatch Logs.
_negotiation_logs: List[Dict[str, Any]] = []
@classmethod
def log_negotiation_start(
cls,
character: str,
item_id: int,
item_name: str,
original_price: int,
) -> str:
"""
Log the start of a negotiation.
Returns:
session_id: Unique identifier for this negotiation session
"""
session_id = str(uuid.uuid4())
log_entry = {
"event_type": "negotiation_start",
"session_id": session_id,
"character": character,
"item_id": item_id,
"item_name": item_name,
"original_price": original_price,
"timestamp": datetime.utcnow().isoformat(),
}
cls._negotiation_logs.append(log_entry)
logger.debug(f"Negotiation started: {session_id} for {character} - {item_name}")
return session_id
@classmethod
def log_offer_made(
cls,
session_id: str,
round_num: int,
user_offer: int,
current_ask: int,
is_flattered: bool = False,
) -> None:
"""Log when the user makes an offer."""
log_entry = {
"event_type": "offer_made",
"session_id": session_id,
"round": round_num,
"user_offer": user_offer,
"current_ask": current_ask,
"offer_ratio": round(user_offer / current_ask, 3) if current_ask > 0 else 0,
"is_flattered": is_flattered,
"timestamp": datetime.utcnow().isoformat(),
}
cls._negotiation_logs.append(log_entry)
logger.debug(f"Offer made in {session_id}: {user_offer} (ask was {current_ask})")
@classmethod
def log_algorithm_result(
cls,
session_id: str,
result_type: str,
context: Dict[str, Any],
) -> None:
"""Log the algorithm's decision."""
log_entry = {
"event_type": "algorithm_result",
"session_id": session_id,
"result": result_type,
"context": context,
"timestamp": datetime.utcnow().isoformat(),
}
cls._negotiation_logs.append(log_entry)
logger.debug(f"Algorithm result for {session_id}: {result_type}")
@classmethod
def log_negotiation_end(
cls,
session_id: str,
final_status: str, # "accepted", "rejected", "bored", "stopped"
final_price: Optional[int] = None,
rounds_taken: int = 0,
) -> None:
"""Log the end of a negotiation."""
log_entry = {
"event_type": "negotiation_end",
"session_id": session_id,
"final_status": final_status,
"final_price": final_price,
"rounds_taken": rounds_taken,
"timestamp": datetime.utcnow().isoformat(),
}
cls._negotiation_logs.append(log_entry)
logger.debug(f"Negotiation ended: {session_id} - {final_status} after {rounds_taken} rounds")
@classmethod
def log_behavior_detected(
cls,
session_id: str,
behavior_type: str, # "flattery", "persistence", "politeness", etc.
) -> None:
"""Log when a user behavior is detected."""
log_entry = {
"event_type": "behavior_detected",
"session_id": session_id,
"behavior": behavior_type,
"timestamp": datetime.utcnow().isoformat(),
}
cls._negotiation_logs.append(log_entry)
logger.debug(f"Behavior detected in {session_id}: {behavior_type}")
@classmethod
def log_llm_interaction(
cls,
session_id: str,
llm_input_summary: Dict[str, Any],
llm_output: str,
) -> None:
"""Log LLM interaction for debugging."""
log_entry = {
"event_type": "llm_interaction",
"session_id": session_id,
"llm_prompt_fields": list(llm_input_summary.keys()),
"llm_output_length": len(llm_output),
"timestamp": datetime.utcnow().isoformat(),
}
cls._negotiation_logs.append(log_entry)
logger.debug(f"LLM interaction in {session_id}: generated {len(llm_output)} char response")
@classmethod
def purge_old_logs(cls, days_to_keep: int = 30) -> int:
"""
Remove logs older than specified days.
Returns:
Number of logs removed
"""
cutoff_date = (datetime.utcnow() - timedelta(days=days_to_keep)).isoformat()
initial_count = len(cls._negotiation_logs)
cls._negotiation_logs = [
log for log in cls._negotiation_logs
if log.get("timestamp", "") > cutoff_date
]
removed_count = initial_count - len(cls._negotiation_logs)
if removed_count > 0:
logger.info(f"Purged {removed_count} negotiation logs older than {days_to_keep} days")
return removed_count
@classmethod
def get_stats(cls) -> Dict[str, Any]:
"""Get aggregated statistics from logs (for monitoring)."""
if not cls._negotiation_logs:
return {
"total_logs": 0,
"negotiation_sessions": 0,
}
# Count unique sessions
sessions = set()
accepted_count = 0
rejected_count = 0
flattery_count = 0
for log in cls._negotiation_logs:
if log.get("session_id"):
sessions.add(log["session_id"])
if log.get("event_type") == "negotiation_end":
if log.get("final_status") == "accepted":
accepted_count += 1
elif log.get("final_status") == "rejected":
rejected_count += 1
if log.get("is_flattered"):
flattery_count += 1
return {
"total_logs": len(cls._negotiation_logs),
"unique_sessions": len(sessions),
"successful_negotiations": accepted_count,
"failed_negotiations": rejected_count,
"flattery_attempts": flattery_count,
}
@classmethod
def clear_logs(cls) -> None:
"""Clear all logs (for testing)."""
cls._negotiation_logs = []

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,441 @@
"""Quest generation service for NPC-driven quest creation.
Generates semantically coherent, character-appropriate quests based on:
- NPC character personality
- Conversation context
- LOTR theme adherence
"""
import json
import logging
import random
import re
from typing import Any, Dict, List, Optional, Tuple
from flask import current_app
from openai import AzureOpenAI
from models.location import Location
from services.character_profiles import get_character_profile, get_quest_affinity
# Configure logging
logger = logging.getLogger(__name__)
# Quest generation templates organized by NPC
QUEST_GENERATION_TEMPLATES: Dict[str, Dict[str, Any]] = {
"frodo": {
"contexts": [
"The user mentions a problem or burden they're carrying.",
"The user asks for guidance on what to do next.",
"The user seems overwhelmed by many tasks.",
],
"themes": [
"Hidden paths and solutions",
"Understanding the true nature of a problem",
"Journeys to unfamiliar places",
"Tests of character and courage",
],
"title_seeds": [
"Uncover the {nature} of {subject}",
"Journey to {location} and discover {objective}",
"Face your doubt about {challenge}",
"Find the hidden wisdom in {situation}",
],
"types": ["The Journey", "The Fellowship", "The Ring"],
"priorities": ["Important", "Critical"],
},
"sam": {
"contexts": [
"The user has completed something and needs momentum.",
"The user asks for practical help or advice.",
"The user seems stuck and needs encouragement.",
],
"themes": [
"Building and creating",
"Practical problem-solving",
"Loyalty and companionship",
"Caring for others or a place",
],
"title_seeds": [
"Prepare {place} for {purpose}",
"Build or fix {object} for {reason}",
"Gather supplies: {list}",
"Care for {person_or_place} by {action}",
],
"types": ["The Fellowship", "The Battle", "The Journey"],
"priorities": ["Important", "Standard"],
},
"gandalf": {
"contexts": [
"The user has reached a critical decision point.",
"The user is avoiding an important choice.",
"The user asks for strategic guidance.",
],
"themes": [
"Strategic choices with large consequences",
"Testing someone's resolve or wisdom",
"Understanding larger patterns",
"Containing or confronting darkness",
],
"title_seeds": [
"Decide the fate of {stakes}",
"Confront {threat} before it spreads",
"Understand the pattern of {mystery}",
"Test your resolve: {challenge}",
],
"types": ["The Ring", "Dark Magic", "The Battle"],
"priorities": ["Critical", "Important"],
},
}
# Fallback quest generation (no AI)
FALLBACK_QUESTS: Dict[str, List[Dict[str, Any]]] = {
"frodo": [
{
"title": "Discover the Heart of the Matter",
"description": "Consider this problem deeply: what lies at its true center? It may appear different when you understand its nature.",
"quest_type": "The Journey",
"priority": "Important",
},
{
"title": "Walk the Hidden Path",
"description": "Every great challenge has an unexpected approach. Take time to find the unconventional route forward.",
"quest_type": "The Fellowship",
"priority": "Important",
},
{
"title": "Test Your Courage",
"description": "Sometimes the next step demands we face what we've been avoiding. What fear guards your path?",
"quest_type": "The Ring",
"priority": "Critical",
},
],
"sam": [
{
"title": "Prepare the Ground",
"description": "Good work starts with preparation. Gather what you need and organize it well before beginning.",
"quest_type": "The Fellowship",
"priority": "Important",
},
{
"title": "Strengthen Your Bonds",
"description": "Reach out and help a companion with something they're struggling with. Loyalty matters.",
"quest_type": "The Fellowship",
"priority": "Standard",
},
{
"title": "Build Something That Lasts",
"description": "Create or improve something that will help you and others in the times ahead.",
"quest_type": "The Battle",
"priority": "Important",
},
],
"gandalf": [
{
"title": "Recognize the Pattern",
"description": "Step back and observe the larger picture. What do the recent events tell you about the true state of affairs?",
"quest_type": "The Ring",
"priority": "Critical",
},
{
"title": "Make the Hard Choice",
"description": "A decision looms that cannot be avoided. Choose based on principle, not comfort.",
"quest_type": "The Ring",
"priority": "Critical",
},
{
"title": "Confront the Advancing Shadow",
"description": "A threat grows. Take action now before it becomes unstoppable.",
"quest_type": "Dark Magic",
"priority": "Critical",
},
],
}
# Middle-earth locations mapping (case-insensitive)
MIDDLE_EARTH_LOCATIONS: Dict[str, List[str]] = {
"Rivendell": ["rivendell", "elrond's home", "valley of imladris", "imladris"],
"Lothlórien": ["lothlórien", "lothlórien", "golden wood", "caras galadhon"],
"Moria": ["moria", "khazad-dum", "dwarf kingdom", "mines of moria"],
"Mordor": ["mordor", "sauron's realm", "mount doom", "barad-dûr"],
"Rohan": ["rohan", "rolling plains", "mark", "edoras"],
"Gondor": ["gondor", "minas tirith", "white city", "kingdom of men"],
"The Shire": ["the shire", "shire", "hobbiton", "bag end"],
"Isengard": ["isengard", "orthanc", "wizard's tower"],
"Mirkwood": ["mirkwood", "greenwood", "thranduil", "wood-elves"],
"Lake-town": ["lake-town", "esgaroth", "bard", "barrel rider"],
"The Grey Havens": ["grey havens", "grey havens", "valinor", "undying lands", "sailing west"],
"Erebor": ["erebor", "lonely mountain", "dwarf kingdom"],
"The Grey Mountains": ["grey mountains", "misty mountains", "mountains"],
}
def _find_location_by_text(text: str) -> Optional[Tuple[str, int]]:
"""Extract and find a location from text.
Searches through MIDDLE_EARTH_LOCATIONS and the database to find mentions.
Returns the Location name and ID that was mentioned in the text.
Args:
text: Text to search for location mentions
Returns:
Tuple of (location_name, location_id) or None
"""
if not text:
return None
text_lower = text.lower()
# Search by known aliases
for location_name, aliases in MIDDLE_EARTH_LOCATIONS.items():
for alias in aliases:
if alias in text_lower:
# Try to find this location in database
try:
location = Location.query.filter_by(name=location_name).first()
if location:
return (location_name, location.id)
except Exception as e:
logger.warning(f"Failed to query location {location_name}: {e}")
return (location_name, None)
return None
def _add_location_to_quest(quest: Dict[str, Any]) -> Dict[str, Any]:
"""Add location_id to a quest based on location mention in description.
Searches the quest description for Middle-earth location mentions
and adds location_id if found.
Args:
quest: Quest dict with title, description, quest_type, priority
Returns:
Same quest dict with optional location_id added
"""
if not quest.get("description"):
return quest
location_result = _find_location_by_text(quest["description"])
if location_result:
location_name, location_id = location_result
if location_id:
quest["location_id"] = location_id
logger.debug(f"✓ Assigned location '{location_name}' (ID: {location_id}) to quest")
return quest
def _new_azure_client() -> Optional[AzureOpenAI]:
"""Create Azure OpenAI client if configured."""
endpoint = current_app.config.get("AZURE_OPENAI_ENDPOINT", "")
api_key = current_app.config.get("AZURE_OPENAI_API_KEY", "")
api_version = current_app.config.get("AZURE_OPENAI_API_VERSION", "2024-02-15-preview")
if not endpoint or not api_key:
return None
return AzureOpenAI(
azure_endpoint=endpoint,
api_key=api_key,
api_version=api_version,
)
def _generate_quest_with_ai(
character: str,
user_message: str,
conversation_history: List[Dict[str, str]],
) -> Optional[Dict[str, Any]]:
"""Generate a quest using Azure OpenAI.
Args:
character: NPC character (frodo, sam, gandalf)
user_message: User's latest message
conversation_history: Recent conversation turns
Returns:
Generated quest dict or None if AI fails
"""
deployment = current_app.config.get("AZURE_OPENAI_DEPLOYMENT", "")
if not deployment:
return None
client = _new_azure_client()
if client is None:
return None
profile = get_character_profile(character)
quest_types = get_quest_affinity(character)
# Build system prompt for quest generation with better context awareness
character_context = ""
if character == "frodo":
character_context = (
"Frodo speaks of quests related to the Ring, Mordor, journeys, and bearing burdens. "
"He frames activities as part of a larger quest toward freedom. "
"Suggest locations like 'Rivendell', 'Lothlórien', 'Moria', or 'Mordor'. "
)
elif character == "sam":
character_context = (
"Sam thinks in practical terms: building, preparing, defending, growing. "
"He frames quests around making things better and stronger. "
"Suggest locations like 'The Shire', 'Gondor', or 'The Grey Havens'. "
)
elif character == "gandalf":
character_context = (
"Gandalf sees the bigger strategic picture and long-term consequences. "
"He frames quests as moves in a grand strategy against darkness. "
"Suggest locations like 'Isengard', 'Orthanc', 'Moria', or 'The Grey Havens'. "
)
system_prompt = f"""You are {profile.get('full_name')}, {profile.get('title')}.
{character_context}
Your job: Create a quest that:
1. Directly ties to what the user just said in conversation
2. Feels authentic to {character}'s personality and way of thinking
3. Uses one of these quest types: {", ".join(quest_types)}
4. Is achievable yet substantial and meaningful
5. Is set in Middle-earthsuggest a specific location
6. Frames it in {character}'s voice and perspective
Respond with ONLY valid JSON (no markdown, no explanation):
{{
"title": "Quest name (4-8 words, action-oriented)",
"description": "2-3 sentences: (a) what the quest entails, (b) why it matters, (c) which location it involves",
"quest_type": "{quest_types[0]}",
"priority": "Important"
}}"""
# Build conversation context with better preservation of dialogue flow
messages = [{"role": "system", "content": system_prompt}]
# Include last few turns to understand context
for turn in conversation_history[-6:]:
messages.append({"role": turn["role"], "content": turn["content"]})
# Ask for quest based on the actual conversation, not just latest message
context_summary = f"""Based on what I just heard, here's what stands out as a quest opportunity:
Latest from the user: "{user_message}"
Now, {profile.get('full_name')}, what quest would help them move forward?"""
messages.append({"role": "user", "content": context_summary})
try:
completion = client.chat.completions.create(
model=deployment,
messages=messages,
max_tokens=300,
temperature=0.8,
)
response_text = (completion.choices[0].message.content or "").strip()
# Try to parse JSON
quest_data = json.loads(response_text)
# Validate required fields
if all(k in quest_data for k in ["title", "description", "quest_type", "priority"]):
# Add location to quest if mentioned in description
quest_data = _add_location_to_quest(quest_data)
logger.info(f"✓ Azure OpenAI generated quest for {character}")
return quest_data
except Exception as e:
logger.error(f"✗ Quest generation failed for {character}: {type(e).__name__}: {str(e)}")
pass
return None
def generate_quest(
character: str,
user_message: str,
conversation_history: Optional[List[Dict[str, str]]] = None,
) -> Optional[Dict[str, Any]]:
"""Generate a quest appropriate to the NPC character.
Uses AI if available, falls back to templates + randomization.
Always attempts to assign a location based on quest description.
Args:
character: NPC character (frodo, sam, gandalf)
user_message: User's latest message
conversation_history: Recent conversation turns (optional)
Returns:
Quest dict with title, description, quest_type, priority, and optional location_id
"""
if not conversation_history:
conversation_history = []
# Try AI generation first
ai_quest = _generate_quest_with_ai(character, user_message, conversation_history)
if ai_quest:
return ai_quest
# Fall back to template-based generation
if character not in FALLBACK_QUESTS:
character = "gandalf"
quest = random.choice(FALLBACK_QUESTS[character])
# Add location to fallback quest too
quest = _add_location_to_quest(quest)
return quest
def should_offer_quest(user_message: str, conversation_turn_count: int = 0) -> bool:
"""Determine if this is a good moment to offer a quest.
Offers quests when:
- User seems to be looking for direction or action (keywords)
- Early in conversation (turn 1-2) to set the tone
- User asks for help explicitly
- User seems stuck or overwhelmed
Args:
user_message: User's latest message
conversation_turn_count: Number of turns in conversation
Returns:
True if a quest should be offered
"""
message_lower = user_message.lower().strip()
# Strong signals for quest readiness
strong_keywords = [
"help", "stuck", "what next", "what should", "can you suggest",
"guide", "quest", "task", "challenge", "adventure", "action",
"ready", "let's", "should we", "next step", "forward"
]
has_strong_signal = any(kw in message_lower for kw in strong_keywords)
# Softer signals (still valid but need turn count context)
soft_keywords = ["do", "can", "shall", "would", "could", "problem"]
has_soft_signal = any(kw in message_lower for kw in soft_keywords)
# Never offer in the first turn (let conversation start naturally)
if conversation_turn_count < 1:
return False
# Always offer if there's a strong signal
if has_strong_signal:
return True
# Offer in early conversations even with soft signals
if conversation_turn_count <= 2 and has_soft_signal:
return True
# Occasionally offer even without keywords (keep engagement)
if conversation_turn_count == 2 and len(message_lower) > 10:
return True
return False

View File

@ -0,0 +1,125 @@
"""Shop service for bargaining, purchases, balance, and personal stats."""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from models.user import User, db
from models.item import Item
from models.inventory_item import InventoryItem
class ShopService:
"""Business logic for item listings and purchases."""
@classmethod
def list_available_items(cls, character: Optional[str] = None, query_obj=None) -> List[Dict[str, Any]]:
# Allow injection of a mock query object for testing
query = query_obj if query_obj is not None else Item.query.filter(Item.is_sold.is_(False))
if character:
query = query.filter(Item.owner_character == character.lower())
return [item.to_public_dict() for item in query.order_by(Item.id.asc()).all()]
@classmethod
def get_item_public(cls, item_id: int) -> Optional[Dict[str, Any]]:
item = Item.query.get(item_id)
if not item:
return None
return item.to_public_dict()
@classmethod
def get_balance(cls, user_id: int) -> Dict[str, Any]:
user = User.query.get(user_id)
if not user:
raise ValueError('User not found')
return {'gold': user.gold}
@classmethod
def purchase_item(cls, user_id: int, item_id: int, paid_price: int) -> Dict[str, Any]:
user = User.query.get(user_id)
if not user:
raise ValueError('User not found')
item = Item.query.get(item_id)
if not item:
raise ValueError('Item not found')
if item.is_sold:
raise ValueError('Item is already sold')
if paid_price <= 0:
raise ValueError('Paid price must be positive')
if user.gold < paid_price:
raise ValueError('Insufficient gold')
savings_percent = ((item.base_price - paid_price) / item.base_price) * 100 if item.base_price else 0.0
user.gold -= paid_price
item.is_sold = True
entry = InventoryItem(
user_id=user.id,
item_id=item.id,
paid_price=paid_price,
base_price_revealed=item.base_price,
savings_percent=round(savings_percent, 2),
acquired_price=paid_price, # Set to same as paid_price
)
db.session.add(entry)
db.session.commit()
return {
'purchase': entry.to_dict(),
'balance': {'gold': user.gold},
'deal_quality': 'good' if savings_percent > 0 else 'bad' if savings_percent < 0 else 'fair',
}
@classmethod
def get_user_inventory(cls, user_id: int) -> List[Dict[str, Any]]:
entries = (
InventoryItem.query
.filter(InventoryItem.user_id == user_id)
.order_by(InventoryItem.created_at.desc())
.all()
)
return [entry.to_dict() for entry in entries]
@classmethod
def get_user_stats(cls, user_id: int) -> Dict[str, Any]:
entries = cls.get_user_inventory(user_id)
if not entries:
return {
'purchased_count': 0,
'best_bargain_percent': 0,
'average_savings_percent': 0,
}
savings_values = [float(entry['savings_percent']) for entry in entries]
average = sum(savings_values) / len(savings_values)
return {
'purchased_count': len(entries),
'best_bargain_percent': round(max(savings_values), 2),
'average_savings_percent': round(average, 2),
}
@classmethod
def reset_for_tests(cls) -> Dict[str, Any]:
"""Reset shop state for testing: unsell items, reset user gold, clear purchases."""
# Mark all items as not sold
Item.query.update({'is_sold': False})
# Reset all users to 500 gold (initial seed amount per requirements)
User.query.update({'gold': 500})
# Clear all inventory items
InventoryItem.query.delete()
db.session.commit()
return {
'items_reset': Item.query.count(),
'users_reset': User.query.count(),
'purchases_cleared': True,
}

View File

@ -0,0 +1 @@
"""Utility modules for the backend."""

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,204 @@
"""Database initialization and management."""
from flask import Flask
from models.user import db
from sqlalchemy import text
import os
def init_db(app: Flask) -> None:
# Initialize db with app
db.init_app(app)
with app.app_context():
# Handle migrations for existing inventory_items table
try:
result = db.session.execute(text("SELECT name FROM sqlite_master WHERE type='table' AND name='inventory_items'"))
table_exists = result.fetchone() is not None
if table_exists:
result = db.session.execute(text("PRAGMA table_info(inventory_items)"))
columns = [row[1] for row in result]
if 'paid_price' not in columns:
db.session.execute(text("ALTER TABLE inventory_items ADD COLUMN paid_price INTEGER DEFAULT 0"))
print("Added paid_price column to inventory_items")
if 'base_price_revealed' not in columns:
db.session.execute(text("ALTER TABLE inventory_items ADD COLUMN base_price_revealed INTEGER DEFAULT 0"))
print("Added base_price_revealed column to inventory_items")
if 'savings_percent' not in columns:
db.session.execute(text("ALTER TABLE inventory_items ADD COLUMN savings_percent FLOAT DEFAULT 0"))
print("Added savings_percent column to inventory_items")
if 'created_at' not in columns:
db.session.execute(text("ALTER TABLE inventory_items ADD COLUMN created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP"))
print("Added created_at column to inventory_items")
# Fix for legacy acquired_price column: add if missing, make nullable if present
if 'acquired_price' not in columns:
db.session.execute(text("ALTER TABLE inventory_items ADD COLUMN acquired_price INTEGER NULL"))
print("Added acquired_price column to inventory_items (nullable)")
else:
# Try to make it nullable if not already
try:
db.session.execute(text("ALTER TABLE inventory_items ALTER COLUMN acquired_price DROP NOT NULL"))
print("Made acquired_price column nullable")
except Exception as e:
print(f"Could not alter acquired_price nullability: {e}")
db.session.commit()
print("Inventory items table migration completed successfully")
except Exception as e:
db.session.rollback()
print(f"Inventory items migration note: {e} (this is normal for new databases)")
# Import all models to register them
from models.user import User
from models.quest import Quest
from models.member import Member
from models.location import Location
from models.item import Item
from models.inventory_item import InventoryItem
# Create all tables
db.create_all()
print("Database tables created successfully")
# Handle migrations for existing users table
try:
users_result = db.session.execute(text("PRAGMA table_info(users)"))
user_columns = {row[1]: row[2] for row in users_result}
if 'gold' not in user_columns:
db.session.execute(text("ALTER TABLE users ADD COLUMN gold INTEGER DEFAULT 500"))
print("Added gold column to users")
db.session.execute(text("UPDATE users SET gold = 500 WHERE gold IS NULL"))
db.session.commit()
print("Users table migration completed successfully")
except Exception as e:
db.session.rollback()
print(f"Users migration note: {e} (this is normal for new databases)")
# Handle migrations for existing quests table
try:
# Check if quests table exists and has old columns
result = db.session.execute(text("PRAGMA table_info(quests)"))
columns = {row[1]: row[2] for row in result}
# Add new columns if they don't exist
if 'quest_type' not in columns:
db.session.execute(text("ALTER TABLE quests ADD COLUMN quest_type VARCHAR(50)"))
print("Added quest_type column")
if 'priority' not in columns:
db.session.execute(text("ALTER TABLE quests ADD COLUMN priority VARCHAR(20)"))
print("Added priority column")
if 'is_dark_magic' not in columns:
db.session.execute(text("ALTER TABLE quests ADD COLUMN is_dark_magic BOOLEAN DEFAULT 0"))
print("Added is_dark_magic column")
if 'character_quote' not in columns:
db.session.execute(text("ALTER TABLE quests ADD COLUMN character_quote TEXT"))
print("Added character_quote column")
if 'completed_at' not in columns:
db.session.execute(text("ALTER TABLE quests ADD COLUMN completed_at DATETIME"))
print("Added completed_at column")
# Migrate status values from old to new LOTR terminology
status_mapping = {
'pending': 'not_yet_begun',
'in_progress': 'the_road_goes_ever_on',
'completed': 'it_is_done',
'blocked': 'the_shadow_falls'
}
for old_status, new_status in status_mapping.items():
db.session.execute(
text("UPDATE quests SET status = :new_status WHERE status = :old_status"),
{'new_status': new_status, 'old_status': old_status}
)
# Update default status for new quests
db.session.execute(text("UPDATE quests SET status = 'not_yet_begun' WHERE status = 'pending'"))
db.session.commit()
print("Database migration completed successfully")
except Exception as e:
# If migration fails, rollback and continue (table might be new)
db.session.rollback()
print(f"Migration note: {e} (this is normal for new databases)")
# Handle migrations for existing locations table
try:
# Check if locations table exists
result = db.session.execute(text("SELECT name FROM sqlite_master WHERE type='table' AND name='locations'"))
table_exists = result.fetchone() is not None
if table_exists:
# Get existing columns
result = db.session.execute(text("PRAGMA table_info(locations)"))
columns = [row[1] for row in result] # row[1] is the column name
# Add new columns if they don't exist
if 'map_x' not in columns:
db.session.execute(text("ALTER TABLE locations ADD COLUMN map_x REAL"))
print("Added map_x column to locations")
if 'map_y' not in columns:
db.session.execute(text("ALTER TABLE locations ADD COLUMN map_y REAL"))
print("Added map_y column to locations")
db.session.commit()
print("Locations table migration completed successfully")
except Exception as e:
# If migration fails, rollback and continue
db.session.rollback()
print(f"Locations migration error: {e}")
import traceback
traceback.print_exc()
# Handle migrations for existing items table
try:
result = db.session.execute(text("SELECT name FROM sqlite_master WHERE type='table' AND name='items'"))
table_exists = result.fetchone() is not None
if table_exists:
result = db.session.execute(text("PRAGMA table_info(items)"))
columns = [row[1] for row in result]
if 'owner_character' not in columns:
db.session.execute(text("ALTER TABLE items ADD COLUMN owner_character VARCHAR(80) DEFAULT 'gandalf'"))
print("Added owner_character column to items")
if 'personality_profile' not in columns:
db.session.execute(text("ALTER TABLE items ADD COLUMN personality_profile VARCHAR(40) DEFAULT 'bargainer'"))
print("Added personality_profile column to items")
if 'asking_price' not in columns:
db.session.execute(text("ALTER TABLE items ADD COLUMN asking_price INTEGER DEFAULT 100"))
print("Added asking_price column to items")
if 'is_sold' not in columns:
db.session.execute(text("ALTER TABLE items ADD COLUMN is_sold BOOLEAN DEFAULT 0"))
print("Added is_sold column to items")
if 'created_at' not in columns:
db.session.execute(text("ALTER TABLE items ADD COLUMN created_at DATETIME"))
print("Added created_at column to items")
if 'updated_at' not in columns:
db.session.execute(text("ALTER TABLE items ADD COLUMN updated_at DATETIME"))
print("Added updated_at column to items")
db.session.execute(text("UPDATE items SET asking_price = COALESCE(asking_price, base_price, 100)"))
db.session.execute(text("UPDATE items SET personality_profile = COALESCE(personality_profile, 'bargainer')"))
db.session.execute(text("UPDATE items SET owner_character = COALESCE(owner_character, 'gandalf')"))
db.session.execute(text("UPDATE items SET created_at = COALESCE(created_at, CURRENT_TIMESTAMP)"))
db.session.execute(text("UPDATE items SET updated_at = COALESCE(updated_at, CURRENT_TIMESTAMP)"))
db.session.commit()
print("Items table migration completed successfully")
except Exception as e:
db.session.rollback()
print(f"Items migration note: {e} (this is normal for new databases)")

View File

@ -0,0 +1,602 @@
"""Seed data initialization for the Fellowship Quest Tracker."""
from models.user import User, db
from models.member import Member
from models.location import Location
from models.quest import Quest
from models.item import Item
from flask import Flask
from typing import List, Dict, Any
def seed_members() -> List[Member]:
"""Create Fellowship members."""
members_data = [
{
'name': 'Frodo Baggins',
'race': 'Hobbit',
'role': 'Ring-bearer',
'status': 'active',
'description': 'The brave hobbit who carries the One Ring to Mount Doom.'
},
{
'name': 'Samwise Gamgee',
'race': 'Hobbit',
'role': 'Companion',
'status': 'active',
'description': 'Frodo\'s loyal friend and companion on the journey.'
},
{
'name': 'Aragorn',
'race': 'Human',
'role': 'Ranger',
'status': 'active',
'description': 'The rightful heir to the throne of Gondor.'
},
{
'name': 'Legolas',
'race': 'Elf',
'role': 'Archer',
'status': 'active',
'description': 'Elven prince and master archer from Mirkwood.'
},
{
'name': 'Gimli',
'race': 'Dwarf',
'role': 'Warrior',
'status': 'active',
'description': 'Dwarf warrior from the Lonely Mountain.'
},
{
'name': 'Gandalf',
'race': 'Wizard',
'role': 'Guide',
'status': 'active',
'description': 'The Grey Wizard who guides the Fellowship.'
}
]
members = []
for data in members_data:
member = Member.query.filter_by(name=data['name']).first()
if not member:
member = Member(**data)
db.session.add(member)
members.append(member)
else:
members.append(member)
db.session.commit()
return members
def seed_locations() -> List[Location]:
"""Create Middle-earth locations.
Coordinates are pixel-based, matching the MiddleEarthMap coordinate system.
Map image dimensions: 5000x4344 pixels (width x height).
Coordinates from MiddleEarthMap by Yohann Bethoule (https://github.com/YohannBethoule/MiddleEarthMap).
Includes all 45 locations from the original MiddleEarthMap markers.json.
"""
locations_data = [
# Eriador
{
'name': 'Hobbiton',
'region': 'Eriador',
'description': 'Hobbiton was a hobbit village in the central regions of the Shire, within the borders of the Westfarthing.',
'map_x': 1482.0,
'map_y': 1158.0
},
{
'name': 'The Shire',
'region': 'Eriador',
'description': 'The peaceful homeland of the Hobbits.',
'map_x': 1482.0,
'map_y': 1158.0
},
{
'name': 'Bree',
'region': 'Eriador',
'description': 'Bree was the chief village of Bree-land, a small wooded region near the intersection of the main north-south and east-west routes through Eriador. Bree-land was the only part of Middle-earth where Men and hobbits dwelt side by side and Bree had a large population of Hobbits.',
'map_x': 1793.0,
'map_y': 1163.0
},
{
'name': 'Rivendell',
'region': 'Eriador',
'description': 'Rivendell was established by Elrond in S.A. 1697 as a refuge from Sauron after the Fall of Eregion. It remained Elrond\'s seat throughout the remainder of the Second Age and until the end of the Third Age, when he took the White Ship for Valinor.',
'map_x': 2516.0,
'map_y': 1123.0
},
{
'name': 'Grey Havens',
'region': 'Eriador',
'description': 'Founded by the Elves of Lindon in S.A. 1, the Grey Havens were known for their good harbourage and many ships; these were used by any of the Eldar to leave Middle-earth for Eressëa or Valinor.',
'map_x': 1047.0,
'map_y': 1186.0
},
{
'name': 'Weathertop',
'region': 'Eriador',
'description': 'In T.A.3018, Amun Sûl was the scene of two fights involving the Nazgûl: one with Gandalf on October 3 and one with the Ring-bearer three days later.',
'map_x': 2000.0,
'map_y': 1158.0
},
# Rhovanion
{
'name': 'Esgaroth',
'region': 'Rhovanion',
'description': 'Lake-Town was the township of the Lake-men in Wilderland. The town was constructed entirely of wood and stood upon wooden pillars sunk into the bed of the Long Lake, as a protection against the dragon Smaug, who dwelt nearby in the Lonely Mountain.',
'map_x': 3418.0,
'map_y': 885.0
},
{
'name': 'Erebor',
'region': 'Rhovanion',
'description': 'The Longbeards had control of Erebor since at least the early Second Age. With the awakening of Durin\'s Bane in the capital of Khazad-dûm, Thráin I led a group of Dwarves to Erebor. Once there, the dwarves dug caves and halls to form an underground city, thus establishing the Kingdom under the Mountain in T.A. 1999.',
'map_x': 3405.0,
'map_y': 825.0
},
{
'name': 'Lothlórien',
'region': 'Rhovanion',
'description': 'Lothlórien (or Lórien) was a kingdom of Silvan Elves on the eastern side of the Hithaeglir. It was considered one of the most beautiful and "elvish" places in Middle-earth during the Third Age, and had the only mallorn-trees east of the sea.',
'map_x': 2666.0,
'map_y': 1679.0
},
{
'name': 'Elvenking\'s Hall',
'region': 'Rhovanion',
'description': 'Elvenking\'s Hall were a cave system in northern Mirkwood, in which King Thranduil and many of the Elves of Mirkwood lived during most of the Third Age and into the Fourth Age.',
'map_x': 3311.0,
'map_y': 849.0
},
{
'name': 'Dol Guldur',
'region': 'Rhovanion',
'description': 'Dol Guldur ("Hill of Sorcery" in Sindarin), also called "the dungeons of the Necromancer", was a stronghold of Sauron located in the south of Mirkwood.',
'map_x': 3014.0,
'map_y': 1629.0
},
{
'name': 'Edoras',
'region': 'Rhovanion',
'description': 'Edoras was the capital of Rohan that held the Golden Hall of Meduseld. Rohan\'s first capital was at Aldburg in the Folde, until King Eorl the Young or his son Brego built Edoras in T.A. 2569.',
'map_x': 2589.0,
'map_y': 2383.0
},
{
'name': 'Rohan',
'region': 'Rhovanion',
'description': 'The land of the Horse-lords.',
'map_x': 2589.0,
'map_y': 2383.0
},
{
'name': 'Helm\'s Deep',
'region': 'Rhovanion',
'description': 'Helm\'s Deep was a large valley gorge in the north-western Ered Nimrais (White Mountains) below the Thrihyrne. It was actually the name of the whole defensive system including its major defensive structure, the Hornburg.',
'map_x': 2423.0,
'map_y': 2321.0
},
{
'name': 'Beorn\'s Hall',
'region': 'Rhovanion',
'description': 'Beorn\'s Hall was the home of Beorn, a powerful Skin-changer. Beorn hosted and aided Thorin and Company during their Quest for Erebor.',
'map_x': 2871.0,
'map_y': 1016.0
},
{
'name': 'Dale',
'region': 'Rhovanion',
'description': 'Dale was a great city of the Northmen which was destroyed by Smaug and rebuilt as the capital of a great kingdom after his demise.',
'map_x': 3430.0,
'map_y': 855.0
},
# Misty Mountains
{
'name': 'Moria',
'region': 'Misty Mountains',
'description': 'Khazad-dûm was the grandest and most famous of the mansions of the Dwarves. There, for many thousands of years, a thriving Dwarvish community created the greatest city ever known.',
'map_x': 2492.0,
'map_y': 1505.0
},
{
'name': 'Goblin-town',
'region': 'Misty Mountains',
'description': 'Goblin-town was a Goblin dwelling under the Misty Mountains, which was ruled by the Great Goblin. Goblin-town was a series of tunnels and caverns, which went all the way through the mountains, with a "back door" (the Goblin-gate) near the Eagle\'s Eyrie in Wilderland, which served as a means of escape, and an access to the Wilderland.',
'map_x': 2647.0,
'map_y': 980.0
},
# Mordor
{
'name': 'Mount Doom',
'region': 'Mordor',
'description': 'Melkor created Mount Doom in the First Age. When Sauron chose the land of Mordor as his dwelling-place in the Second Age, Orodruin was the reason for his choice. The mountain erupted in S.A. 3429, signalling Sauron\'s attack on Gondor and it took the name Amon Amarth, "Mount Doom". This is where the One Ring was forged by Sauron, and where it was destroyed by Gollum.',
'map_x': 3606.0,
'map_y': 2603.0
},
{
'name': 'Mordor',
'region': 'Mordor',
'description': 'The dark land of Sauron, where the One Ring was forged.',
'map_x': 3606.0,
'map_y': 2603.0
},
{
'name': 'Minas Morgul',
'region': 'Mordor',
'description': 'Minas Morgul (originally called Minas Ithil) was the twin city of Minas Tirith before its fall to the forces of Sauron in the Third Age. It then became the stronghold of the Witch-king of Angmar until Sauron\'s defeat.',
'map_x': 3424.0,
'map_y': 2695.0
},
{
'name': 'Black Gate',
'region': 'Mordor',
'description': 'The Black Gate was the main entrance into the land of Mordor. It was built by Sauron after he chose Mordor as a land to make into a stronghold in S.A. 1000.',
'map_x': 3389.0,
'map_y': 2377.0
},
{
'name': 'Barad-dûr',
'region': 'Mordor',
'description': 'Barad-dûr, also known as the Dark Tower, was the chief fortress of Sauron, on the Plateau of Gorgoroth in Mordor. Sauron began to build Barad-dûr in around S.A. 1000, and completed his fortress after 600 years of the construction with the power of the Ring.',
'map_x': 3750.0,
'map_y': 2553.0
},
# Gondor
{
'name': 'Minas Tirith',
'region': 'Gondor',
'description': 'Minas Tirith was originally a fortress, Minas Anor, built in S.A. 3320 by the Faithful Númenóreans. From T.A. 1640 onwards it was the capital of the South-kingdom and the seat of its Kings and ruling Stewards.',
'map_x': 3279.0,
'map_y': 2707.0
},
{
'name': 'Osgiliath',
'region': 'Gondor',
'description': 'Founded by Isildur and Anárion near the end of the Second Age, Osgiliath was designated the capital of the southern Númenórean kingdom in exile, Gondor. It stays so until the King\'s House was moved to the more secure Minas Anor in T.A. 1640.',
'map_x': 3330.0,
'map_y': 2700.0
},
{
'name': 'Paths of the Dead',
'region': 'Gondor',
'description': 'The Paths of the Dead was a haunted underground passage through the White Mountains that led from Harrowdale in Rohan to Blackroot Vale in Gondor.',
'map_x': 2605.0,
'map_y': 2535.0
},
# Isengard
{
'name': 'Isengard',
'region': 'Isengard',
'description': 'Isengard was one of the three major fortresses of Gondor, and held within it one of the realm\'s palantíri. In the latter half of the Third Age, the stronghold came into the possession of Saruman, becoming his home and personal domain until his defeat in the War of the Ring.',
'map_x': 2335.0,
'map_y': 2117.0
},
# Angmar
{
'name': 'Carn Dûm',
'region': 'Angmar',
'description': 'Carn Dûm was the chief fortress of the realm of Angmar and the seat of its king until its defeat against the combined armies of Gondor, Lindon and Arnor in T.A. 1974.',
'map_x': 2115.0,
'map_y': 523.0
},
{
'name': 'Mount Gram',
'region': 'Angmar',
'description': 'Mount Gram was inhabited by Orcs led by their King Golfimbul. In T.A. 2747 they attacked much of northern Eriador, but were defeated in the Battle of Greenfields.',
'map_x': 2353.0,
'map_y': 746.0
}
]
locations = []
for data in locations_data:
location = Location.query.filter_by(name=data['name']).first()
if not location:
location = Location(**data)
db.session.add(location)
locations.append(location)
else:
# Update existing location with coordinates if missing
if location.map_x is None or location.map_y is None:
location.map_x = data.get('map_x')
location.map_y = data.get('map_y')
locations.append(location)
db.session.commit()
return locations
def seed_users(members: List[Member]) -> List[User]:
"""Create user accounts for Fellowship members."""
users = []
default_password = 'fellowship123' # Simple password for MVP
for member in members:
user = User.query.filter_by(username=member.name.lower().replace(' ', '_')).first()
if not user:
user = User(
username=member.name.lower().replace(' ', '_'),
email=f"{member.name.lower().replace(' ', '_')}@fellowship.com",
role=member.name,
gold=500,
)
user.set_password(default_password)
db.session.add(user)
users.append(user)
else:
if user.gold is None:
user.gold = 500
users.append(user)
db.session.commit()
return users
def seed_quests(locations: List[Location], users: List[User]) -> List[Quest]:
"""Create initial quests with epic descriptions and LOTR attributes."""
quests_data = [
{
'title': 'Destroy the One Ring',
'description': 'Journey to the fires of Mount Doom and cast the Ring into the flames where it was forged. The fate of Middle-earth depends on this quest.',
'status': 'the_road_goes_ever_on',
'quest_type': 'The Ring',
'priority': 'Critical',
'is_dark_magic': False,
'character_quote': 'I will take the Ring, though I do not know the way.',
'location_name': 'Mount Doom', # Use specific location name
'assignee_username': 'frodo_baggins'
},
{
'title': 'Reach Rivendell',
'description': 'Travel to Rivendell to seek counsel from Elrond. The Last Homely House awaits, where the Fellowship will be formed and the path forward decided.',
'status': 'it_is_done',
'quest_type': 'The Journey',
'priority': 'Important',
'is_dark_magic': False,
'character_quote': 'The Road goes ever on and on...',
'location_name': 'Rivendell',
'assignee_username': 'frodo_baggins'
},
{
'title': 'Cross the Misty Mountains',
'description': 'Navigate through the treacherous Misty Mountains, avoiding the dangers that lurk in the shadows and the watchful eyes of the enemy.',
'status': 'it_is_done',
'quest_type': 'The Journey',
'priority': 'Important',
'is_dark_magic': False,
'character_quote': None,
'location_name': 'Moria',
'assignee_username': 'aragorn'
},
{
'title': 'Escape from Moria',
'description': 'Flee from the depths of Moria as the Balrog awakens. The Fellowship must escape before the darkness consumes them.',
'status': 'it_is_done',
'quest_type': 'The Battle',
'priority': 'Critical',
'is_dark_magic': False,
'character_quote': 'Fly, you fools!',
'location_name': 'Moria',
'assignee_username': 'gandalf'
},
{
'title': 'Reach Mordor',
'description': 'Travel to the dark land of Mordor, where Sauron\'s power is strongest. The journey grows more perilous with each step.',
'status': 'the_road_goes_ever_on',
'quest_type': 'The Journey',
'priority': 'Critical',
'is_dark_magic': False,
'character_quote': None,
'location_name': 'Mordor', # Keep generic name, will match either "Mordor" or "Mount Doom"
'assignee_username': 'frodo_baggins'
},
{
'title': 'Fix the Broken Bridge',
'description': 'Sauron\'s dark magic has corrupted the bridge. The Fellowship must restore it to continue their journey. This quest has been tainted by dark forces.',
'status': 'the_shadow_falls',
'quest_type': 'Dark Magic',
'priority': 'Critical',
'is_dark_magic': True,
'character_quote': None,
'location_name': 'Edoras', # Use specific location name
'assignee_username': 'samwise_gamgee'
},
{
'title': 'Rescue Merry and Pippin',
'description': 'The Fellowship must rescue the captured Hobbits from the Uruk-hai. Time is running out, and the fate of our friends hangs in the balance.',
'status': 'not_yet_begun',
'quest_type': 'The Fellowship',
'priority': 'Important',
'is_dark_magic': False,
'character_quote': None,
'location_name': 'Edoras', # Use specific location name
'assignee_username': 'aragorn'
},
{
'title': 'Defend Helm\'s Deep',
'description': 'Stand with the people of Rohan as they face the armies of Saruman. The battle will be fierce, but courage and unity will prevail.',
'status': 'not_yet_begun',
'quest_type': 'The Battle',
'priority': 'Critical',
'is_dark_magic': False,
'character_quote': None,
'location_name': 'Helm\'s Deep', # Use specific location name
'assignee_username': 'aragorn'
}
]
quests = []
for data in quests_data:
# Find location
location = next((loc for loc in locations if loc.name == data['location_name']), None)
# Find user
user = next((u for u in users if u.username == data['assignee_username']), None)
quest = Quest.query.filter_by(title=data['title']).first()
if not quest:
quest = Quest(
title=data['title'],
description=data['description'],
status=data['status'],
quest_type=data.get('quest_type'),
priority=data.get('priority'),
is_dark_magic=data.get('is_dark_magic', False),
character_quote=data.get('character_quote'),
location_id=location.id if location else None,
assigned_to=user.id if user else None
)
# Set completed_at if quest is done
if data['status'] == 'it_is_done':
from datetime import datetime
quest.completed_at = datetime.utcnow()
db.session.add(quest)
quests.append(quest)
else:
# Update existing quest with new fields if they're missing
if quest.quest_type is None and data.get('quest_type'):
quest.quest_type = data.get('quest_type')
if quest.priority is None and data.get('priority'):
quest.priority = data.get('priority')
if quest.is_dark_magic is False and data.get('is_dark_magic'):
quest.is_dark_magic = data.get('is_dark_magic')
if quest.character_quote is None and data.get('character_quote'):
quest.character_quote = data.get('character_quote')
# Update location_id if missing or if location name matches
if quest.location_id is None and location:
quest.location_id = location.id
elif quest.location_id is None:
# Try to find location by name if not found initially
location = next((loc for loc in locations if loc.name == data['location_name']), None)
if location:
quest.location_id = location.id
# Migrate old status values
status_mapping = {
'pending': 'not_yet_begun',
'in_progress': 'the_road_goes_ever_on',
'completed': 'it_is_done',
'blocked': 'the_shadow_falls'
}
if quest.status in status_mapping:
quest.status = status_mapping[quest.status]
quests.append(quest)
db.session.commit()
return quests
def seed_items() -> List[Item]:
"""Create initial unique seller items for bargaining gameplay."""
items_data = [
{
'name': 'Sting-polished Scabbard',
'description': 'A meticulously maintained hobbit scabbard with Elvish runes.',
'owner_character': 'frodo',
'personality_profile': 'sentimental',
'base_price': 140,
'asking_price': 195,
},
{
'name': 'Shire Herb Satchel',
'description': 'Sam\'s hand-stitched satchel, still smelling faintly of rosemary.',
'owner_character': 'sam',
'personality_profile': 'generous',
'base_price': 95,
'asking_price': 120,
},
{
'name': 'Grey Pilgrim Pipe',
'description': 'A weathered pipe with intricate wizard-carved symbols.',
'owner_character': 'gandalf',
'personality_profile': 'bargainer',
'base_price': 260,
'asking_price': 360,
},
{
'name': 'Second Breakfast Pan',
'description': 'A surprisingly sturdy pan fit for long roads and many meals.',
'owner_character': 'sam',
'personality_profile': 'bargainer',
'base_price': 70,
'asking_price': 98,
},
{
'name': 'Wizard Hat (Scuffed Edition)',
'description': 'A tall, dramatic hat with glorious wear and a few mysterious burns.',
'owner_character': 'gandalf',
'personality_profile': 'stingy',
'base_price': 210,
'asking_price': 315,
},
{
'name': 'Mithril Shield',
'description': 'A legendary shield forged from mithril, light yet stronger than steel.',
'owner_character': 'frodo',
'personality_profile': 'sentimental',
'base_price': 350,
'asking_price': 450,
},
{
'name': 'Sword of Elendil',
'description': 'An ancient sword with a storied history from the days of old.',
'owner_character': 'frodo',
'personality_profile': 'bargainer',
'base_price': 400,
'asking_price': 500,
},
{
'name': 'Sword of Narsil',
'description': 'A legendary blade shattered and reforged with great power.',
'owner_character': 'frodo',
'personality_profile': 'stingy',
'base_price': 450,
'asking_price': 550,
},
{
'name': 'Lembas Bread',
'description': 'Elvish lembas bread that sustains travelers on long journeys.',
'owner_character': 'sam',
'personality_profile': 'generous',
'base_price': 150,
'asking_price': 180,
},
{
'name': 'Elven Rope',
'description': 'A strong and graceful rope crafted by Elven artisans.',
'owner_character': 'frodo',
'personality_profile': 'bargainer',
'base_price': 80,
'asking_price': 110,
},
]
seeded_items: List[Item] = []
for payload in items_data:
item = Item.query.filter_by(name=payload['name']).first()
if not item:
item = Item(**payload)
db.session.add(item)
seeded_items.append(item)
db.session.commit()
return seeded_items
def seed_database(app: Flask) -> None:
"""Seed the database with initial data."""
with app.app_context():
print("Seeding database...")
# Seed in order: members -> locations -> users -> quests
members = seed_members()
print(f"Seeded {len(members)} members")
locations = seed_locations()
print(f"Seeded {len(locations)} locations")
users = seed_users(members)
print(f"Seeded {len(users)} users")
quests = seed_quests(locations, users)
print(f"Seeded {len(quests)} quests")
items = seed_items()
print(f"Seeded {len(items)} market items")
print("Database seeding completed!")

View File

@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Verify Azure OpenAI configuration is loaded correctly."""
import sys
from pathlib import Path
# Add backend to path
backend_dir = Path(__file__).parent
sys.path.insert(0, str(backend_dir))
from config import Config
def main():
config = Config()
print("\n" + "=" * 70)
print("Azure OpenAI Configuration Status")
print("=" * 70)
has_endpoint = bool(config.AZURE_OPENAI_ENDPOINT)
has_api_key = bool(config.AZURE_OPENAI_API_KEY)
status = "✅ ACTIVE" if (has_endpoint and has_api_key) else "❌ NOT CONFIGURED"
print(f"\nStatus: {status}\n")
print(f"Endpoint: {config.AZURE_OPENAI_ENDPOINT if has_endpoint else '(not set)'}")
print(f"API Key: {'(loaded from .env)' if has_api_key else '(not set)'}")
print(f"Deployment: {config.AZURE_OPENAI_DEPLOYMENT}")
print(f"API Version: {config.AZURE_OPENAI_API_VERSION}")
print(f"Max Tokens: {config.AZURE_OPENAI_MAX_TOKENS}")
print(f"Temperature: {config.AZURE_OPENAI_TEMPERATURE}")
print("\n" + "=" * 70)
if has_endpoint and has_api_key:
print("🤖 AI-powered NPC responses are ACTIVE")
print("🎯 Context-aware quest generation is ENABLED")
print("\nNPC conversations will now:")
print(" • Use character personalities for authentic responses")
print(" • Reference user's specific situation in replies")
print(" • Generate quests matched to conversation context")
print(" • Fall back to templates only if API fails")
return 0
else:
print("⚠️ Azure OpenAI not configured")
print("\nTo enable AI:")
print(" 1. Create/update .env file with Azure credentials")
print(" 2. Restart the backend service")
return 1
if __name__ == '__main__':
sys.exit(main())

16
sut/frontend/.env.example Normal file
View File

@ -0,0 +1,16 @@
# Frontend Environment Variables
# Copy this to .env.local and update values for your environment
# API Configuration
REACT_APP_API_URL=http://localhost/api
# Site URL Configuration (used for SEO and analytics)
# Set this to your deployment URL for correct sitemap.xml and meta tags
# Examples:
# - Development: http://localhost:5173
# - Staging: https://lotr-staging.testingfantasy.com
# - Production: https://lotr-prod.testingfantasy.com
VITE_APP_SITE_URL=http://localhost:5173
# Google Analytics
REACT_APP_GA_ID=G-29N4KD7MQ9

6
sut/frontend/.env.local Normal file
View File

@ -0,0 +1,6 @@
# WebSocket configuration for dev server behind HTTPS proxy (Caddy)
# This ensures webpack dev server uses secure WebSockets when frontend is served over HTTPS
WDS_SOCKET_PROTOCOL=wss
WDS_SOCKET_PORT=80
WDS_SOCKET_HOST=localhost
WDS_SOCKET_PATH=/ws

24
sut/frontend/Dockerfile Normal file
View File

@ -0,0 +1,24 @@
FROM node:20-alpine
WORKDIR /app
# Copy package files
COPY package*.json ./
COPY tsconfig.json ./
# Install dependencies
RUN npm install
# Copy source files
COPY . .
# Expose port
EXPOSE 3000
# Disable react-refresh for Docker environment
ENV SKIP_PREFLIGHT_CHECK=true
ENV DISABLE_ESLINT_PLUGIN=true
ENV FAST_REFRESH=false
# Start development server
CMD ["npm", "start"]

View File

@ -0,0 +1,169 @@
# Environment-Aware URL Configuration
This document explains how analytics and SEO URLs adapt to different deployment environments.
## Overview
The Fellowship Quest List uses environment-aware URLs in critical files:
- **`index.html`**: Meta tags (canonical, og:url, og:image, twitter:image)
- **`sitemap.xml`**: All routes and API endpoints
This ensures that regardless of whether you're deploying to:
- Development: `http://localhost:5173`
- Staging: `https://lotr-staging.testingfantasy.com`
- Production: `https://lotr-prod.testingfantasy.com`
The URLs in these files are always correct.
## Environment Variables
### Primary Variable
`VITE_APP_SITE_URL` - The full site URL without trailing slash
- Example: `https://lotr-prod.testingfantasy.com`
- Example: `https://lotr-staging.testingfantasy.com`
- Example: `http://localhost:5173`
## How It Works
### For `index.html`
URLs are set dynamically at **runtime** using JavaScript:
1. Base URLs in HTML use placeholders: `%VITE_APP_SITE_URL%`
2. At page load, JavaScript reads `window.location.origin`
3. Dynamic updates to `<meta>` and `<link>` tags ensure they reflect the actual deployment URL
4. This works for all deployment scenarios without rebuilding
### For `sitemap.xml`
URLs are substituted **at build time**:
1. Source file uses placeholders: `%VITE_APP_SITE_URL%`
2. Build script replaces placeholders with actual environment URL
3. Final `sitemap.xml` has concrete URLs for search engines
## Setup Instructions
### Development
```bash
cd sut/frontend
# Run dev server (uses http://localhost:5173 automatically)
npm run dev
```
### Build for Deployment
```bash
cd sut/frontend
# Set the environment-specific URL
export VITE_APP_SITE_URL="https://lotr-prod.testingfantasy.com"
# Build the application
npm run build
# Run setup script to update XML files with correct URLs
node scripts/setup-env-urls.js
```
### Docker Deployment
In your Dockerfile:
```dockerfile
ARG SITE_URL=https://lotr-prod.testingfantasy.com
# ... build steps ...
# Setup environment-aware URLs
ENV VITE_APP_SITE_URL=${SITE_URL}
RUN cd sut/frontend && node scripts/setup-env-urls.js
```
### CI/CD Pipeline
Example GitHub Actions:
```yaml
- name: Setup environment-aware URLs
env:
VITE_APP_SITE_URL: ${{ secrets.SITE_URL }}
run: |
cd sut/frontend
node scripts/setup-env-urls.js
```
## File Examples
### index.html (Runtime Dynamic)
```html
<!-- Placeholder URLs in source -->
<meta property="og:url" content="%VITE_APP_SITE_URL%/" id="og-url" />
<link rel="canonical" href="%VITE_APP_SITE_URL%/" id="canonical" />
<!-- JavaScript updates them at runtime -->
<script>
const origin = window.location.origin;
document.getElementById('og-url').content = origin + '/';
document.getElementById('canonical').href = origin + '/';
</script>
```
### sitemap.xml (Build-time Substitution)
```xml
<!-- Before build (source) -->
<loc>%VITE_APP_SITE_URL%/login</loc>
<!-- After build with VITE_APP_SITE_URL=https://lotr-prod.testingfantasy.com -->
<loc>https://lotr-prod.testingfantasy.com/login</loc>
```
## Testing URLs
### Verify index.html Dynamic URLs
```bash
# Open browser DevTools and check the Console when page loads
# Meta tags should update to match your current URL
# Example: If accessed at https://mysite.com
# - og:url should be "https://mysite.com/"
# - canonical should be "https://mysite.com/"
```
### Verify sitemap.xml
```bash
# Download sitemap.xml and check the URLs
curl https://lotr-prod.testingfantasy.com/sitemap.xml | head -20
# All <loc> entries should use the correct domain
```
## Benefits
**Single codebase** - Deploy to any environment without code changes
**Search engines** - Correct canonical URLs prevent duplicate content penalties
**Social media** - Correct og: tags for rich previews on any domain
**Analytics** - Proper tracking in GA regardless of deployment URL
**No rebuilds** - index.html works without rebuild for different domains
## Troubleshooting
### URLs not updating in sitemap.xml
- Ensure `VITE_APP_SITE_URL` is set before building
- Run `node scripts/setup-env-urls.js` after build
- Check that `public/sitemap.xml` contains your domain, not `%VITE_APP_SITE_URL%`
### Meta tags not updating in index.html
- Open browser DevTools (F12)
- Go to Elements/Inspector and check `<meta id="og-url">` etc.
- Verify JavaScript ran: check Console for any errors
- URL should match `window.location.origin`
### Staging environment has wrong URLs
- Verify `VITE_APP_SITE_URL` environment variable is set
- Run setup script before deploying
- Check that sitemap.xml contains staging URL, not production
## References
- [Google Canonical URLs](https://developers.google.com/search/docs/beginner/seo-starter-guide#declare-the-canonical-version-of-a-page)
- [Open Graph Protocol](https://ogp.me/)
- [Sitemap Protocol](https://www.sitemaps.org/protocol.html)

View File

@ -0,0 +1,13 @@
{
"files": {
"main.css": "/static/css/main.787d33f1.css",
"main.js": "/static/js/main.cdb3f2d7.js",
"index.html": "/index.html",
"main.787d33f1.css.map": "/static/css/main.787d33f1.css.map",
"main.cdb3f2d7.js.map": "/static/js/main.cdb3f2d7.js.map"
},
"entrypoints": [
"static/css/main.787d33f1.css",
"static/js/main.cdb3f2d7.js"
]
}

View File

@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="The Fellowship Quest Tracker - Track your journey through Middle-earth. Create, manage, and complete quests in a LOTR-themed web application with bargaining, mini-games, and NPC companions."/><meta name="keywords" content="Middle-earth, quests, tracking, fellowship, Lord of the Rings, adventure, interactive"/><meta name="author" content="TestingFantasy Team"/><meta property="og:type" content="website"/><meta property="og:title" content="Fellowship Quest Tracker - Middle-earth Adventure"/><meta property="og:description" content="Track your epic journey through Middle-earth. Create quests, bargain with NPCs, play mini-games, and manage your inventory."/><meta property="og:url" content="https://lotr-prod.testingfantasy.com/" id="og-url"/><meta property="og:site_name" content="Fellowship Quest Tracker"/><meta property="og:image" content="https://lotr-prod.testingfantasy.com/og-image.png" id="og-image"/><meta property="og:image:width" content="1200"/><meta property="og:image:height" content="630"/><meta name="twitter:card" content="summary_large_image"/><meta name="twitter:title" content="Fellowship Quest Tracker"/><meta name="twitter:description" content="Track your journey through Middle-earth with quests, bargaining, and adventures."/><meta name="twitter:image" content="https://lotr-prod.testingfantasy.com/og-image.png" id="twitter-image"/><meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1"/><link rel="canonical" href="https://lotr-prod.testingfantasy.com/" id="canonical"/><link rel="alternate" hreflang="en" href="https://lotr-prod.testingfantasy.com/" id="hreflang"/><title>Fellowship Quest Tracker - Track Your Middle-earth Adventure</title><script async src="https://www.googletagmanager.com/gtag/js?id=G-29N4KD7MQ9"></script><script>function gtag(){dataLayer.push(arguments)}window.dataLayer=window.dataLayer||[],gtag("js",new Date),gtag("config","G-29N4KD7MQ9"),function(){const t=window.location.origin,e=t.endsWith("/")?t.slice(0,-1):t,n=document.getElementById("canonical");n&&(n.href=e+"/");const o=document.getElementById("hreflang");o&&(o.href=e+"/");const g=document.getElementById("og-url");g&&(g.content=e+"/");const a=document.getElementById("og-image");a&&(a.content=e+"/og-image.png");const c=document.getElementById("twitter-image");c&&(c.content=e+"/og-image.png")}()</script><script defer="defer" src="/static/js/main.cdb3f2d7.js"></script><link href="/static/css/main.787d33f1.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>

View File

@ -0,0 +1,767 @@
/* required styles */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
position: absolute;
left: 0;
top: 0;
}
.leaflet-container {
overflow: hidden;
}
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-user-drag: none;
}
/* Prevents IE11 from highlighting tiles in blue */
.leaflet-tile::selection {
background: transparent;
}
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
.leaflet-safari .leaflet-tile {
image-rendering: -webkit-optimize-contrast;
}
/* hack that prevents hw layers "stretching" when loading new tiles */
.leaflet-safari .leaflet-tile-container {
width: 1600px;
height: 1600px;
-webkit-transform-origin: 0 0;
}
.leaflet-marker-icon,
.leaflet-marker-shadow {
display: block;
}
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
.leaflet-container .leaflet-overlay-pane svg {
max-width: none !important;
max-height: none !important;
}
.leaflet-container .leaflet-marker-pane img,
.leaflet-container .leaflet-shadow-pane img,
.leaflet-container .leaflet-tile-pane img,
.leaflet-container img.leaflet-image-layer,
.leaflet-container .leaflet-tile {
max-width: none !important;
max-height: none !important;
width: auto;
padding: 0;
}
.leaflet-container.leaflet-touch-zoom {
-ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
}
.leaflet-container.leaflet-touch-drag {
-ms-touch-action: pinch-zoom;
/* Fallback for FF which doesn't support pinch-zoom */
touch-action: none;
touch-action: pinch-zoom;
}
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
-ms-touch-action: none;
touch-action: none;
}
.leaflet-container {
-webkit-tap-highlight-color: transparent;
}
.leaflet-container a {
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
}
.leaflet-tile {
filter: inherit;
visibility: hidden;
}
.leaflet-tile-loaded {
visibility: inherit;
}
.leaflet-zoom-box {
width: 0;
height: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
z-index: 800;
}
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
.leaflet-overlay-pane svg {
-moz-user-select: none;
}
.leaflet-pane {
z-index: 400;
}
.leaflet-tile-pane {
z-index: 200;
}
.leaflet-overlay-pane {
z-index: 400;
}
.leaflet-shadow-pane {
z-index: 500;
}
.leaflet-marker-pane {
z-index: 600;
}
.leaflet-tooltip-pane {
z-index: 650;
}
.leaflet-popup-pane {
z-index: 700;
}
.leaflet-map-pane canvas {
z-index: 100;
}
.leaflet-map-pane svg {
z-index: 200;
}
.leaflet-vml-shape {
width: 1px;
height: 1px;
}
.lvml {
behavior: url(#default#VML);
display: inline-block;
position: absolute;
}
/* control positioning */
.leaflet-control {
position: relative;
z-index: 800;
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
.leaflet-top,
.leaflet-bottom {
position: absolute;
z-index: 1000;
pointer-events: none;
}
.leaflet-top {
top: 0;
}
.leaflet-right {
right: 0;
}
.leaflet-bottom {
bottom: 0;
}
.leaflet-left {
left: 0;
}
.leaflet-control {
float: left;
clear: both;
}
.leaflet-right .leaflet-control {
float: right;
}
.leaflet-top .leaflet-control {
margin-top: 10px;
}
.leaflet-bottom .leaflet-control {
margin-bottom: 10px;
}
.leaflet-left .leaflet-control {
margin-left: 10px;
}
.leaflet-right .leaflet-control {
margin-right: 10px;
}
/* zoom and fade animations */
.leaflet-fade-anim .leaflet-popup {
opacity: 0;
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
opacity: 1;
}
.leaflet-zoom-animated {
-webkit-transform-origin: 0 0;
-ms-transform-origin: 0 0;
transform-origin: 0 0;
}
svg.leaflet-zoom-animated {
will-change: transform;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0, 0, 0.25, 1);
-moz-transition: -moz-transform 0.25s cubic-bezier(0, 0, 0.25, 1);
transition: transform 0.25s cubic-bezier(0, 0, 0.25, 1);
}
.leaflet-zoom-anim .leaflet-tile,
.leaflet-pan-anim .leaflet-tile {
-webkit-transition: none;
-moz-transition: none;
transition: none;
}
.leaflet-zoom-anim .leaflet-zoom-hide {
visibility: hidden;
}
/* cursors */
.leaflet-interactive {
cursor: pointer;
}
.leaflet-grab {
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.leaflet-crosshair,
.leaflet-crosshair .leaflet-interactive {
cursor: crosshair;
}
.leaflet-popup-pane,
.leaflet-control {
cursor: auto;
}
.leaflet-dragging .leaflet-grab,
.leaflet-dragging .leaflet-grab .leaflet-interactive,
.leaflet-dragging .leaflet-marker-draggable {
cursor: move;
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
/* marker & overlays interactivity */
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-image-layer,
.leaflet-pane > svg path,
.leaflet-tile-container {
pointer-events: none;
}
.leaflet-marker-icon.leaflet-interactive,
.leaflet-image-layer.leaflet-interactive,
.leaflet-pane > svg path.leaflet-interactive,
svg.leaflet-image-layer.leaflet-interactive path {
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
/* visual tweaks */
.leaflet-container {
background: #ddd;
outline-offset: 1px;
}
.leaflet-container a {
color: #0078A8;
}
.leaflet-zoom-box {
border: 2px dotted #38f;
background: rgba(255, 255, 255, 0.5);
}
/* general typography */
.leaflet-container {
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
font-size: 12px;
font-size: 0.75rem;
line-height: 1.5;
}
/* general toolbar styles */
.leaflet-bar {
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.65);
border-radius: 4px;
}
.leaflet-bar a {
background-color: #fff;
border-bottom: 1px solid #ccc;
width: 26px;
height: 26px;
line-height: 26px;
display: block;
text-align: center;
text-decoration: none;
color: black;
}
.leaflet-bar a,
.leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
}
.leaflet-bar a:hover,
.leaflet-bar a:focus {
background-color: #f4f4f4;
}
.leaflet-bar a:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.leaflet-bar a:last-child {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom: none;
}
.leaflet-bar a.leaflet-disabled {
cursor: default;
background-color: #f4f4f4;
color: #bbb;
}
.leaflet-touch .leaflet-bar a {
width: 30px;
height: 30px;
line-height: 30px;
}
.leaflet-touch .leaflet-bar a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.leaflet-touch .leaflet-bar a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
/* zoom control */
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
font: bold 18px 'Lucida Console', Monaco, monospace;
text-indent: 1px;
}
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
font-size: 22px;
}
/* layers control */
.leaflet-control-layers {
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.4);
background: #fff;
border-radius: 5px;
}
.leaflet-control-layers-toggle {
background-image: url(assets/images/layers.png);
width: 36px;
height: 36px;
}
.leaflet-retina .leaflet-control-layers-toggle {
background-image: url(assets/images/layers-2x.png);
background-size: 26px 26px;
}
.leaflet-touch .leaflet-control-layers-toggle {
width: 44px;
height: 44px;
}
.leaflet-control-layers .leaflet-control-layers-list,
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
display: none;
}
.leaflet-control-layers-expanded .leaflet-control-layers-list {
display: block;
position: relative;
}
.leaflet-control-layers-expanded {
padding: 6px 10px 6px 6px;
color: #333;
background: #fff;
}
.leaflet-control-layers-scrollbar {
overflow-y: scroll;
overflow-x: hidden;
padding-right: 5px;
}
.leaflet-control-layers-selector {
margin-top: 2px;
position: relative;
top: 1px;
}
.leaflet-control-layers label {
display: block;
font-size: 13px;
font-size: 1.08333em;
}
.leaflet-control-layers-separator {
height: 0;
border-top: 1px solid #ddd;
margin: 5px -10px 5px -6px;
}
/* Default icon URLs */
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
background-image: url(assets/images/marker-icon.png);
}
/* attribution and scale controls */
.leaflet-container .leaflet-control-attribution {
background: #fff;
background: rgba(255, 255, 255, 0.8);
margin: 0;
}
.leaflet-control-attribution,
.leaflet-control-scale-line {
padding: 0 5px;
color: #333;
line-height: 1.4;
}
.leaflet-control-attribution a {
text-decoration: none;
}
.leaflet-control-attribution a:hover,
.leaflet-control-attribution a:focus {
text-decoration: underline;
}
.leaflet-attribution-flag {
display: inline !important;
vertical-align: baseline !important;
width: 1em;
height: 0.6669em;
}
.leaflet-left .leaflet-control-scale {
margin-left: 5px;
}
.leaflet-bottom .leaflet-control-scale {
margin-bottom: 5px;
}
.leaflet-control-scale-line {
border: 2px solid #777;
border-top: none;
line-height: 1.1;
padding: 2px 5px 1px;
white-space: nowrap;
-moz-box-sizing: border-box;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.8);
text-shadow: 1px 1px #fff;
}
.leaflet-control-scale-line:not(:first-child) {
border-top: 2px solid #777;
border-bottom: none;
margin-top: -2px;
}
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
border-bottom: 2px solid #777;
}
.leaflet-touch .leaflet-control-attribution,
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
box-shadow: none;
}
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
border: 2px solid rgba(0, 0, 0, 0.2);
background-clip: padding-box;
}
/* popup */
.leaflet-popup {
position: absolute;
text-align: center;
margin-bottom: 20px;
}
.leaflet-popup-content-wrapper {
padding: 1px;
text-align: left;
border-radius: 12px;
}
.leaflet-popup-content {
margin: 13px 24px 13px 20px;
line-height: 1.3;
font-size: 13px;
font-size: 1.08333em;
min-height: 1px;
}
.leaflet-popup-content p {
margin: 17px 0;
margin: 1.3em 0;
}
.leaflet-popup-tip-container {
width: 40px;
height: 20px;
position: absolute;
left: 50%;
margin-top: -1px;
margin-left: -20px;
overflow: hidden;
pointer-events: none;
}
.leaflet-popup-tip {
width: 17px;
height: 17px;
padding: 1px;
margin: -10px auto 0;
pointer-events: auto;
-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: white;
color: #333;
box-shadow: 0 3px 14px rgba(0, 0, 0, 0.4);
}
.leaflet-container a.leaflet-popup-close-button {
position: absolute;
top: 0;
right: 0;
border: none;
text-align: center;
width: 24px;
height: 24px;
font: 16px/24px Tahoma, Verdana, sans-serif;
color: #757575;
text-decoration: none;
background: transparent;
}
.leaflet-container a.leaflet-popup-close-button:hover,
.leaflet-container a.leaflet-popup-close-button:focus {
color: #585858;
}
.leaflet-popup-scrolled {
overflow: auto;
}
.leaflet-oldie .leaflet-popup-content-wrapper {
-ms-zoom: 1;
}
.leaflet-oldie .leaflet-popup-tip {
width: 24px;
margin: 0 auto;
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
}
.leaflet-oldie .leaflet-control-zoom,
.leaflet-oldie .leaflet-control-layers,
.leaflet-oldie .leaflet-popup-content-wrapper,
.leaflet-oldie .leaflet-popup-tip {
border: 1px solid #999;
}
/* div icon */
.leaflet-div-icon {
background: #fff;
border: 1px solid #666;
}
/* Tooltip */
/* Base styles for the element that has a tooltip */
.leaflet-tooltip {
position: absolute;
padding: 6px;
background-color: #fff;
border: 1px solid #fff;
border-radius: 3px;
color: #222;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
}
.leaflet-tooltip.leaflet-interactive {
cursor: pointer;
pointer-events: auto;
}
.leaflet-tooltip-top:before,
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
position: absolute;
pointer-events: none;
border: 6px solid transparent;
background: transparent;
content: "";
}
/* Directions */
.leaflet-tooltip-bottom {
margin-top: 6px;
}
.leaflet-tooltip-top {
margin-top: -6px;
}
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-top:before {
left: 50%;
margin-left: -6px;
}
.leaflet-tooltip-top:before {
bottom: 0;
margin-bottom: -12px;
border-top-color: #fff;
}
.leaflet-tooltip-bottom:before {
top: 0;
margin-top: -12px;
margin-left: -6px;
border-bottom-color: #fff;
}
.leaflet-tooltip-left {
margin-left: -6px;
}
.leaflet-tooltip-right {
margin-left: 6px;
}
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
top: 50%;
margin-top: -6px;
}
.leaflet-tooltip-left:before {
right: 0;
margin-right: -12px;
border-left-color: #fff;
}
.leaflet-tooltip-right:before {
left: 0;
margin-left: -12px;
border-right-color: #fff;
}
/* Printing */
@media print {
/* Prevent printers from removing background-images of controls. */
.leaflet-control {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,68 @@
.marker-cluster-small {
background-color: rgba(181, 226, 140, 0.6);
}
.marker-cluster-small div {
background-color: rgba(110, 204, 57, 0.6);
}
.marker-cluster-medium {
background-color: rgba(241, 211, 87, 0.6);
}
.marker-cluster-medium div {
background-color: rgba(240, 194, 12, 0.6);
}
.marker-cluster-large {
background-color: rgba(253, 156, 115, 0.6);
}
.marker-cluster-large div {
background-color: rgba(241, 128, 23, 0.6);
}
/* IE 6-8 fallback colors */
.leaflet-oldie .marker-cluster-small {
background-color: rgb(181, 226, 140);
}
.leaflet-oldie .marker-cluster-small div {
background-color: rgb(110, 204, 57);
}
.leaflet-oldie .marker-cluster-medium {
background-color: rgb(241, 211, 87);
}
.leaflet-oldie .marker-cluster-medium div {
background-color: rgb(240, 194, 12);
}
.leaflet-oldie .marker-cluster-large {
background-color: rgb(253, 156, 115);
}
.leaflet-oldie .marker-cluster-large div {
background-color: rgb(241, 128, 23);
}
.marker-cluster {
background-clip: padding-box;
border-radius: 20px;
}
.marker-cluster div {
width: 30px;
height: 30px;
margin-left: 5px;
margin-top: 5px;
text-align: center;
border-radius: 15px;
font: 12px "Helvetica Neue", Arial, Helvetica, sans-serif;
}
.marker-cluster span {
line-height: 30px;
}

View File

@ -0,0 +1,14 @@
.leaflet-cluster-anim .leaflet-marker-icon, .leaflet-cluster-anim .leaflet-marker-shadow {
-webkit-transition: -webkit-transform 0.3s ease-out, opacity 0.3s ease-in;
-moz-transition: -moz-transform 0.3s ease-out, opacity 0.3s ease-in;
-o-transition: -o-transform 0.3s ease-out, opacity 0.3s ease-in;
transition: transform 0.3s ease-out, opacity 0.3s ease-in;
}
.leaflet-cluster-spider-leg {
/* stroke-dashoffset (duration and function) should match with leaflet-marker-icon transform in order to track it exactly */
-webkit-transition: -webkit-stroke-dashoffset 0.3s ease-out, -webkit-stroke-opacity 0.3s ease-in;
-moz-transition: -moz-stroke-dashoffset 0.3s ease-out, -moz-stroke-opacity 0.3s ease-in;
-o-transition: -o-stroke-dashoffset 0.3s ease-out, -o-stroke-opacity 0.3s ease-in;
transition: stroke-dashoffset 0.3s ease-out, stroke-opacity 0.3s ease-in;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,307 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports["default"] = void 0;
var _leaflet = require("leaflet");
var _BouncingOptions = _interopRequireDefault(require("./BouncingOptions.js"));
var _Cache = _interopRequireDefault(require("./Cache.js"));
var _Styles = _interopRequireDefault(require("./Styles.js"));
function _interopRequireDefault(obj) {
return obj && obj.__esModule ? obj : {"default": obj};
}
function _classCallCheck(instance, Constructor) {
if (!(instance instanceof Constructor)) {
throw new TypeError("Cannot call a class as a function");
}
}
function _defineProperties(target, props) {
for (var i = 0; i < props.length; i++) {
var descriptor = props[i];
descriptor.enumerable = descriptor.enumerable || false;
descriptor.configurable = true;
if ("value" in descriptor) descriptor.writable = true;
Object.defineProperty(target, descriptor.key, descriptor);
}
}
function _createClass(Constructor, protoProps, staticProps) {
if (protoProps) _defineProperties(Constructor.prototype, protoProps);
if (staticProps) _defineProperties(Constructor, staticProps);
return Constructor;
}
function _defineProperty(obj, key, value) {
if (key in obj) {
Object.defineProperty(obj, key, {value: value, enumerable: true, configurable: true, writable: true});
} else {
obj[key] = value;
}
return obj;
}
var bonceEndEvent = 'bounceend';
var BouncingMotion = /*#__PURE__*/function () {
// TODO: check if this cache working right (keys don't need prefix)
/**
* Constructor.
*
* @param marker {Marker} marker
* @param position {Point} marker current position on the map canvas
* @param bouncingOptions {BouncingOptions} options of bouncing animation
*/
function BouncingMotion(marker, position, bouncingOptions) {
_classCallCheck(this, BouncingMotion);
_defineProperty(this, "marker", void 0);
_defineProperty(this, "position", void 0);
_defineProperty(this, "bouncingOptions", void 0);
_defineProperty(this, "moveSteps", void 0);
_defineProperty(this, "moveDelays", void 0);
_defineProperty(this, "resizeSteps", void 0);
_defineProperty(this, "resizeDelays", void 0);
_defineProperty(this, "isBouncing", false);
_defineProperty(this, "iconStyles", void 0);
_defineProperty(this, "shadowStyles", void 0);
_defineProperty(this, "bouncingAnimationPlaying", false);
this.marker = marker;
this.position = position;
this.updateBouncingOptions(bouncingOptions);
}
_createClass(BouncingMotion, [{
key: "updateBouncingOptions",
value: function updateBouncingOptions(options) {
this.bouncingOptions = options instanceof _BouncingOptions["default"] ? options : this.bouncingOptions.override(options);
var _this$bouncingOptions = this.bouncingOptions,
bounceHeight = _this$bouncingOptions.bounceHeight,
bounceSpeed = _this$bouncingOptions.bounceSpeed,
elastic = _this$bouncingOptions.elastic;
this.moveSteps = BouncingMotion.cache.get("moveSteps_".concat(bounceHeight), function () {
return BouncingMotion.calculateSteps(bounceHeight);
});
this.moveDelays = BouncingMotion.cache.get("moveDelays_".concat(bounceHeight, "_").concat(bounceSpeed), function () {
return BouncingMotion.calculateDelays(bounceHeight, bounceSpeed);
});
if (elastic) {
var _this$bouncingOptions2 = this.bouncingOptions,
contractHeight = _this$bouncingOptions2.contractHeight,
contractSpeed = _this$bouncingOptions2.contractSpeed;
this.resizeSteps = BouncingMotion.cache.get("resizeSteps_".concat(contractHeight), function () {
return BouncingMotion.calculateSteps(contractHeight);
});
this.resizeDelays = BouncingMotion.cache.get("resizeDelays_".concat(contractHeight, "_").concat(contractSpeed), function () {
return BouncingMotion.calculateDelays(contractHeight, contractSpeed);
});
}
this.recalculateMotion(this.position);
}
}, {
key: "resetStyles",
value: function resetStyles(marker) {
this.iconStyles = _Styles["default"].ofMarker(marker);
if (marker._shadow) {
this.shadowStyles = _Styles["default"].parse(marker._shadow.style.cssText);
}
}
/**
* Recalculates bouncing motion for new marker position.
* @param position {Point} new marker position
*/
}, {
key: "recalculateMotion",
value: function recalculateMotion(position) {
this.position = position;
}
/**
* @param times {number|null}
*/
}, {
key: "bounce",
value: function bounce() {
var times = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : null;
if (this.bouncingAnimationPlaying) {
this.isBouncing = true;
return;
}
this.isBouncing = true;
this.bouncingAnimationPlaying = true;
this.move(times);
}
}, {
key: "stopBouncing",
value: function stopBouncing() {
this.isBouncing = false;
}
/**
* @param times {number|null}
*/
}, {
key: "move",
value: function move() {
var _this = this;
var times = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : null;
if (times !== null) {
if (!--times) {
this.isBouncing = false; // this is the last bouncing
this.bouncingAnimationPlaying = false;
}
}
/* Launch timeouts for every step of the movement animation */
var i = this.moveSteps.length;
while (i--) {
setTimeout(function (step) {
return _this.makeMoveStep(step);
}, this.moveDelays[i], this.moveSteps[i]);
}
setTimeout(function () {
return _this.afterMove(times);
}, this.moveDelays[this.moveSteps.length - 1]);
}
}, {
key: "afterMove",
value: function afterMove(times) {
var _this2 = this;
if (this.isBouncing) {
setTimeout(function () {
return _this2.move(times);
}, this.bouncingOptions.bounceSpeed);
} else {
this.bouncingAnimationPlaying = false;
this.marker.fire(bonceEndEvent);
}
}
/**
* @param step {number}
*/
}, {
key: "makeMoveStep",
value: function makeMoveStep(step) {
this.marker._icon.style.cssText = this.iconStyles.toString();
if (this.marker._shadow) {
this.marker._shadow.style.cssText = this.shadowStyles.toString();
}
}
/**
* Returns calculated array of animation steps. This function used to calculate both movement
* and resizing animations.
*
* @param height {number} height of movement or resizing (px)
*
* @return {number[]} array of animation steps
*/
}], [{
key: "calculateSteps",
value: function calculateSteps(height) {
/* Calculate the sequence of animation steps:
* steps = [1 .. height] concat [height-1 .. 0]
*/
var i = 1;
var steps = [];
while (i <= height) {
steps.push(i++);
}
i = height;
while (i--) {
steps.push(i);
}
return steps;
}
/**
* Returns calculated array of delays between animation start and the steps of animation. This
* function used to calculate both movement and resizing animations. Element with index i of
* this array contains the delay in milliseconds between animation start and the step number i.
*
* @param height {number} height of movement or resizing (px)
* @param speed {number} speed coefficient
*
* @return {number[]} array of delays before steps of animation
*/
}, {
key: "calculateDelays",
value: function calculateDelays(height, speed) {
// Calculate delta time for bouncing animation
// Delta time to movement in one direction
var deltas = []; // time between steps of animation
deltas[height] = speed;
deltas[0] = 0;
var i = height;
while (--i) {
deltas[i] = Math.round(speed / (height - i));
} // Delta time for movement in two directions
i = height;
while (i--) {
deltas.push(deltas[i]);
} // Calculate move delays (cumulated deltas)
// TODO: instead of deltas.lenght write bounceHeight * 2 - 1
var delays = []; // delays before steps from beginning of animation
var totalDelay = 0;
for (i = 0; i < deltas.length; i++) {
totalDelay += deltas[i];
delays.push(totalDelay);
}
return delays;
}
}]);
return BouncingMotion;
}();
exports["default"] = BouncingMotion;
_defineProperty(BouncingMotion, "cache", new _Cache["default"]());

View File

@ -0,0 +1,373 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports["default"] = void 0;
var _leaflet = require("leaflet");
var _BouncingMotion2 = _interopRequireDefault(require("./BouncingMotion.js"));
var _Matrix3D = _interopRequireDefault(require("./Matrix3D.js"));
var _line = require("./line.js");
function _interopRequireDefault(obj) {
return obj && obj.__esModule ? obj : {"default": obj};
}
function _typeof(obj) {
"@babel/helpers - typeof";
if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") {
_typeof = function _typeof(obj) {
return typeof obj;
};
} else {
_typeof = function _typeof(obj) {
return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj;
};
}
return _typeof(obj);
}
function _classCallCheck(instance, Constructor) {
if (!(instance instanceof Constructor)) {
throw new TypeError("Cannot call a class as a function");
}
}
function _defineProperties(target, props) {
for (var i = 0; i < props.length; i++) {
var descriptor = props[i];
descriptor.enumerable = descriptor.enumerable || false;
descriptor.configurable = true;
if ("value" in descriptor) descriptor.writable = true;
Object.defineProperty(target, descriptor.key, descriptor);
}
}
function _createClass(Constructor, protoProps, staticProps) {
if (protoProps) _defineProperties(Constructor.prototype, protoProps);
if (staticProps) _defineProperties(Constructor, staticProps);
return Constructor;
}
function _get(target, property, receiver) {
if (typeof Reflect !== "undefined" && Reflect.get) {
_get = Reflect.get;
} else {
_get = function _get(target, property, receiver) {
var base = _superPropBase(target, property);
if (!base) return;
var desc = Object.getOwnPropertyDescriptor(base, property);
if (desc.get) {
return desc.get.call(receiver);
}
return desc.value;
};
}
return _get(target, property, receiver || target);
}
function _superPropBase(object, property) {
while (!Object.prototype.hasOwnProperty.call(object, property)) {
object = _getPrototypeOf(object);
if (object === null) break;
}
return object;
}
function _inherits(subClass, superClass) {
if (typeof superClass !== "function" && superClass !== null) {
throw new TypeError("Super expression must either be null or a function");
}
subClass.prototype = Object.create(superClass && superClass.prototype, {
constructor: {
value: subClass,
writable: true,
configurable: true
}
});
if (superClass) _setPrototypeOf(subClass, superClass);
}
function _setPrototypeOf(o, p) {
_setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(o, p) {
o.__proto__ = p;
return o;
};
return _setPrototypeOf(o, p);
}
function _createSuper(Derived) {
var hasNativeReflectConstruct = _isNativeReflectConstruct();
return function _createSuperInternal() {
var Super = _getPrototypeOf(Derived), result;
if (hasNativeReflectConstruct) {
var NewTarget = _getPrototypeOf(this).constructor;
result = Reflect.construct(Super, arguments, NewTarget);
} else {
result = Super.apply(this, arguments);
}
return _possibleConstructorReturn(this, result);
};
}
function _possibleConstructorReturn(self, call) {
if (call && (_typeof(call) === "object" || typeof call === "function")) {
return call;
}
return _assertThisInitialized(self);
}
function _assertThisInitialized(self) {
if (self === void 0) {
throw new ReferenceError("this hasn't been initialised - super() hasn't been called");
}
return self;
}
function _isNativeReflectConstruct() {
if (typeof Reflect === "undefined" || !Reflect.construct) return false;
if (Reflect.construct.sham) return false;
if (typeof Proxy === "function") return true;
try {
Date.prototype.toString.call(Reflect.construct(Date, [], function () {
}));
return true;
} catch (e) {
return false;
}
}
function _getPrototypeOf(o) {
_getPrototypeOf = Object.setPrototypeOf ? Object.getPrototypeOf : function _getPrototypeOf(o) {
return o.__proto__ || Object.getPrototypeOf(o);
};
return _getPrototypeOf(o);
}
function _defineProperty(obj, key, value) {
if (key in obj) {
Object.defineProperty(obj, key, {value: value, enumerable: true, configurable: true, writable: true});
} else {
obj[key] = value;
}
return obj;
}
var moveMatrixFormat = _Matrix3D["default"].identity().toFormat('d1', 'd2');
var resizeMatrixFormat = _Matrix3D["default"].identity().toFormat('b2', 'd1', 'd2');
var BouncingMotion3D = /*#__PURE__*/function (_BouncingMotion) {
_inherits(BouncingMotion3D, _BouncingMotion);
var _super = _createSuper(BouncingMotion3D);
/**
* Constructor.
*
* @param marker {Marker} marker
* @param position {Point} marker current position on the map canvas
* @param bouncingOptions {BouncingOptions} options of bouncing animation
*/
function BouncingMotion3D(marker, position, bouncingOptions) {
var _this;
_classCallCheck(this, BouncingMotion3D);
_this = _super.call(this, marker, position, bouncingOptions);
_defineProperty(_assertThisInitialized(_this), "iconMoveTransforms", void 0);
_defineProperty(_assertThisInitialized(_this), "iconResizeTransforms", void 0);
_defineProperty(_assertThisInitialized(_this), "shadowMoveTransforms", void 0);
_defineProperty(_assertThisInitialized(_this), "shadowResizeTransforms", void 0);
_this.recalculateMotion(position);
return _this;
}
_createClass(BouncingMotion3D, [{
key: "recalculateMotion",
value: function recalculateMotion(position) {
var _this$marker$getIcon, _this$marker$getIcon$, _this$marker, _this$marker$_iconObj,
_this$marker$_iconObj2;
_get(_getPrototypeOf(BouncingMotion3D.prototype), "recalculateMotion", this).call(this, position);
var iconHeight = ((_this$marker$getIcon = this.marker.getIcon()) === null || _this$marker$getIcon === void 0 ? void 0 : (_this$marker$getIcon$ = _this$marker$getIcon.options) === null || _this$marker$getIcon$ === void 0 ? void 0 : _this$marker$getIcon$.iconSize[1]) || ((_this$marker = this.marker) === null || _this$marker === void 0 ? void 0 : (_this$marker$_iconObj = _this$marker._iconObj) === null || _this$marker$_iconObj === void 0 ? void 0 : (_this$marker$_iconObj2 = _this$marker$_iconObj.options) === null || _this$marker$_iconObj2 === void 0 ? void 0 : _this$marker$_iconObj2.iconSize[1]);
var x = position.x,
y = position.y;
var _this$bouncingOptions = this.bouncingOptions,
bounceHeight = _this$bouncingOptions.bounceHeight,
contractHeight = _this$bouncingOptions.contractHeight,
shadowAngle = _this$bouncingOptions.shadowAngle;
this.iconMoveTransforms = BouncingMotion3D.calculateIconMoveTransforms(x, y, bounceHeight);
this.iconResizeTransforms = BouncingMotion3D.calculateResizeTransforms(x, y, iconHeight, contractHeight);
if (this.marker._shadow) {
var _this$marker$getIcon2, _this$marker$getIcon3;
this.shadowMoveTransforms = BouncingMotion3D.calculateShadowMoveTransforms(x, y, bounceHeight, shadowAngle);
var shadowHeight = (_this$marker$getIcon2 = this.marker.getIcon()) === null || _this$marker$getIcon2 === void 0 ? void 0 : (_this$marker$getIcon3 = _this$marker$getIcon2.options) === null || _this$marker$getIcon3 === void 0 ? void 0 : _this$marker$getIcon3.shadowSize[1];
this.shadowResizeTransforms = BouncingMotion3D.calculateResizeTransforms(x, y, shadowHeight, contractHeight);
}
}
}, {
key: "afterMove",
value: function afterMove(times) {
if (this.bouncingOptions.elastic) {
this.resize(times);
} else {
_get(_getPrototypeOf(BouncingMotion3D.prototype), "afterMove", this).call(this, times);
}
}
}, {
key: "resize",
value: function resize(times) {
var _this2 = this;
var nbResizeSteps = this.resizeSteps.length;
var i = nbResizeSteps;
while (i--) {
setTimeout(function (step) {
return _this2.makeResizeStep(step);
}, this.resizeDelays[i], this.resizeSteps[i]);
}
setTimeout(function () {
if (!_this2.isBouncing) {
_this2.bouncingAnimationPlaying = false;
}
}, this.resizeDelays[this.resizeSteps.length]);
setTimeout(function () {
if (_this2.isBouncing) {
_this2.move(times);
} else {
_this2.marker.fire('bounceend');
}
}, this.resizeDelays[nbResizeSteps - 1]);
}
}, {
key: "makeMoveStep",
value: function makeMoveStep(step) {
this.marker._icon.style.cssText = this.iconStyles.withTransform(this.iconMoveTransforms[step]).toString();
if (this.marker._shadow) {
this.marker._shadow.style.cssText = this.shadowStyles.withTransform(this.shadowMoveTransforms[step]).toString();
}
}
/**
* @param step {number}
*/
}, {
key: "makeResizeStep",
value: function makeResizeStep(step) {
this.marker._icon.style.cssText = this.iconStyles.withTransform(this.iconResizeTransforms[step]).toString();
if (this.marker._shadow && this.bouncingOptions.shadowAngle) {
this.marker._shadow.style.cssText = this.shadowStyles.withTransform(this.shadowResizeTransforms[step]).toString();
}
}
/**
* Returns calculated array of transformation definitions for the animation of icon movement.
* Function defines one transform for every pixel of shift of the icon from it's original y
* position.
*
* @param x {number} x coordinate of original position of the marker
* @param y {number} y coordinate of original position of the marker
* @param bounceHeight {number} height of bouncing (px)
*
* @return {string[]} array of transformation definitions
*/
}], [{
key: "calculateIconMoveTransforms",
value: function calculateIconMoveTransforms(x, y, bounceHeight) {
var transforms = [];
var deltaY = bounceHeight + 1; // Use fast inverse while loop to fill the array
while (deltaY--) {
transforms[deltaY] = moveMatrixFormat(x, y - deltaY);
}
return transforms;
}
/**
* Returns calculated array of transformation definitions for the animation of icon resizing.
* Function defines one transform for every pixel of resizing of marker from it's original
* height.
*
* @param x {number} x coordinate of original position of marker
* @param y {number} y coordinate of original position of marker
* @param height {number} original marker height (px)
* @param contractHeight {number} height of marker contraction (px)
*
* @return {string[]} array of transformation definitions
*/
}, {
key: "calculateResizeTransforms",
value: function calculateResizeTransforms(x, y, height, contractHeight) {
var transforms = [];
var deltaHeight = contractHeight + 1; // Use fast inverse while loop to fill the array
while (deltaHeight--) {
transforms[deltaHeight] = resizeMatrixFormat((height - deltaHeight) / height, x, y + deltaHeight);
}
return transforms;
}
/**
* Returns calculated array of transformation definitions for the animation of shadow movement.
* Function defines one transform for every pixel of shift of the shadow from it's original
* position.
*
* @param x {number} x coordinate of original position of marker
* @param y {number} y coordinate of original position of marker
* @param bounceHeight {number} height of bouncing (px)
* @param angle {number|null} shadow inclination angle, if null shadow don't moves from it's
* initial position (radians)
*
* @return {string[]} array of transformation definitions
*/
}, {
key: "calculateShadowMoveTransforms",
value: function calculateShadowMoveTransforms(x, y, bounceHeight) {
var angle = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : null;
// TODO: check this method to know if bounceHeight + 1 is normal
var transforms = [];
var deltaY = bounceHeight + 1;
var points = [];
if (angle != null) {
// important: 0 is not null
points = (0, _line.calculateLine)(x, y, angle, bounceHeight + 1);
} else {
for (var i = 0; i <= bounceHeight; i++) {
points[i] = [x, y];
}
} // Use fast inverse while loop to fill the array
while (deltaY--) {
transforms[deltaY] = moveMatrixFormat(points[deltaY][0], points[deltaY][1]);
}
return transforms;
}
}]);
return BouncingMotion3D;
}(_BouncingMotion2["default"]);
exports["default"] = BouncingMotion3D;

View File

@ -0,0 +1,481 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports["default"] = void 0;
var _leaflet = require("leaflet");
var _line = require("./line.js");
require("./bouncing.css");
var _BouncingOptions = _interopRequireDefault(require("./BouncingOptions.js"));
var _Styles = _interopRequireDefault(require("./Styles.js"));
function _interopRequireDefault(obj) {
return obj && obj.__esModule ? obj : {"default": obj};
}
function _slicedToArray(arr, i) {
return _arrayWithHoles(arr) || _iterableToArrayLimit(arr, i) || _unsupportedIterableToArray(arr, i) || _nonIterableRest();
}
function _nonIterableRest() {
throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.");
}
function _unsupportedIterableToArray(o, minLen) {
if (!o) return;
if (typeof o === "string") return _arrayLikeToArray(o, minLen);
var n = Object.prototype.toString.call(o).slice(8, -1);
if (n === "Object" && o.constructor) n = o.constructor.name;
if (n === "Map" || n === "Set") return Array.from(o);
if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen);
}
function _arrayLikeToArray(arr, len) {
if (len == null || len > arr.length) len = arr.length;
for (var i = 0, arr2 = new Array(len); i < len; i++) {
arr2[i] = arr[i];
}
return arr2;
}
function _iterableToArrayLimit(arr, i) {
var _i = arr == null ? null : typeof Symbol !== "undefined" && arr[Symbol.iterator] || arr["@@iterator"];
if (_i == null) return;
var _arr = [];
var _n = true;
var _d = false;
var _s, _e;
try {
for (_i = _i.call(arr); !(_n = (_s = _i.next()).done); _n = true) {
_arr.push(_s.value);
if (i && _arr.length === i) break;
}
} catch (err) {
_d = true;
_e = err;
} finally {
try {
if (!_n && _i["return"] != null) _i["return"]();
} finally {
if (_d) throw _e;
}
}
return _arr;
}
function _arrayWithHoles(arr) {
if (Array.isArray(arr)) return arr;
}
function _classCallCheck(instance, Constructor) {
if (!(instance instanceof Constructor)) {
throw new TypeError("Cannot call a class as a function");
}
}
function _defineProperties(target, props) {
for (var i = 0; i < props.length; i++) {
var descriptor = props[i];
descriptor.enumerable = descriptor.enumerable || false;
descriptor.configurable = true;
if ("value" in descriptor) descriptor.writable = true;
Object.defineProperty(target, descriptor.key, descriptor);
}
}
function _createClass(Constructor, protoProps, staticProps) {
if (protoProps) _defineProperties(Constructor.prototype, protoProps);
if (staticProps) _defineProperties(Constructor, staticProps);
Object.defineProperty(Constructor, "prototype", {writable: false});
return Constructor;
}
function _classPrivateFieldInitSpec(obj, privateMap, value) {
_checkPrivateRedeclaration(obj, privateMap);
privateMap.set(obj, value);
}
function _checkPrivateRedeclaration(obj, privateCollection) {
if (privateCollection.has(obj)) {
throw new TypeError("Cannot initialize the same private elements twice on an object");
}
}
function _defineProperty(obj, key, value) {
if (key in obj) {
Object.defineProperty(obj, key, {value: value, enumerable: true, configurable: true, writable: true});
} else {
obj[key] = value;
}
return obj;
}
function _classPrivateFieldGet(receiver, privateMap) {
var descriptor = _classExtractFieldDescriptor(receiver, privateMap, "get");
return _classApplyDescriptorGet(receiver, descriptor);
}
function _classApplyDescriptorGet(receiver, descriptor) {
if (descriptor.get) {
return descriptor.get.call(receiver);
}
return descriptor.value;
}
function _classPrivateFieldSet(receiver, privateMap, value) {
var descriptor = _classExtractFieldDescriptor(receiver, privateMap, "set");
_classApplyDescriptorSet(receiver, descriptor, value);
return value;
}
function _classExtractFieldDescriptor(receiver, privateMap, action) {
if (!privateMap.has(receiver)) {
throw new TypeError("attempted to " + action + " private field on non-instance");
}
return privateMap.get(receiver);
}
function _classApplyDescriptorSet(receiver, descriptor, value) {
if (descriptor.set) {
descriptor.set.call(receiver, value);
} else {
if (!descriptor.writable) {
throw new TypeError("attempted to set read only private field");
}
descriptor.value = value;
}
}
var animationNamePrefix = 'l-smooth-marker-bouncing-';
var moveAnimationName = animationNamePrefix + 'move';
var contractAnimationName = animationNamePrefix + 'contract';
/*
* CSS3 animation runs faster than transform-based animation. We need to reduce speed in order
* to be compatible with old API.
*/
var speedCoefficient = 0.8;
/**
* Removes and then resets required classes on the HTML element.
* Used as hack to restart CSS3 animation.
*
* @param element {HTMLElement} HTML element
* @param classes {string[]} names of classes
*/
function resetClasses(element, classes) {
classes.forEach(function (className) {
return _leaflet.DomUtil.removeClass(element, className);
});
void element.offsetWidth;
classes.forEach(function (className) {
return _leaflet.DomUtil.addClass(element, className);
});
}
var _lastAnimationName = /*#__PURE__*/new WeakMap();
var _classes = /*#__PURE__*/new WeakMap();
var _eventCounter = /*#__PURE__*/new WeakMap();
var _times = /*#__PURE__*/new WeakMap();
var _listener = /*#__PURE__*/new WeakMap();
var BouncingMotionCss3 = /*#__PURE__*/function () {
/**
* Constructor.
*
* @param marker {Marker} marker
* @param position {Point} marker current position on the map canvas
* @param bouncingOptions {BouncingOptions} options of bouncing animation
*/
function BouncingMotionCss3(marker, position, bouncingOptions) {
var _this = this;
_classCallCheck(this, BouncingMotionCss3);
_defineProperty(this, "marker", void 0);
_defineProperty(this, "position", void 0);
_defineProperty(this, "bouncingOptions", void 0);
_defineProperty(this, "isBouncing", false);
_defineProperty(this, "iconStyles", void 0);
_defineProperty(this, "shadowStyles", void 0);
_defineProperty(this, "bouncingAnimationPlaying", false);
_classPrivateFieldInitSpec(this, _lastAnimationName, {
writable: true,
value: contractAnimationName
});
_classPrivateFieldInitSpec(this, _classes, {
writable: true,
value: ['bouncing']
});
_classPrivateFieldInitSpec(this, _eventCounter, {
writable: true,
value: void 0
});
_classPrivateFieldInitSpec(this, _times, {
writable: true,
value: void 0
});
_classPrivateFieldInitSpec(this, _listener, {
writable: true,
value: function value(event) {
return _this.onAnimationEnd(event);
}
});
this.marker = marker;
this.position = position;
this.updateBouncingOptions(bouncingOptions);
}
_createClass(BouncingMotionCss3, [{
key: "updateBouncingOptions",
value: function updateBouncingOptions(options) {
this.bouncingOptions = options instanceof _BouncingOptions["default"] ? options : this.bouncingOptions.override(options);
if (this.bouncingOptions.elastic) {
_classPrivateFieldSet(this, _lastAnimationName, contractAnimationName);
var index = _classPrivateFieldGet(this, _classes).indexOf('simple');
if (index > -1) {
_classPrivateFieldGet(this, _classes).splice(index, 1);
}
if (this.marker._icon) {
_leaflet.DomUtil.removeClass(this.marker._icon, 'simple');
}
} else {
_classPrivateFieldSet(this, _lastAnimationName, moveAnimationName);
_classPrivateFieldGet(this, _classes).push('simple');
}
if (this.marker._icon) {
this.resetStyles(this.marker);
}
}
}, {
key: "onAnimationEnd",
value: function onAnimationEnd(event) {
var _this2 = this;
if (event.animationName === _classPrivateFieldGet(this, _lastAnimationName)) {
var _this$eventCounter, _this$eventCounter2;
_classPrivateFieldSet(this, _eventCounter, (_this$eventCounter = _classPrivateFieldGet(this, _eventCounter), _this$eventCounter2 = _this$eventCounter++, _this$eventCounter)), _this$eventCounter2;
_classPrivateFieldSet(this, _eventCounter, _classPrivateFieldGet(this, _eventCounter) % 2);
if (!_classPrivateFieldGet(this, _eventCounter)) {
var _this$times;
if (this.isBouncing && (_classPrivateFieldGet(this, _times) === null || _classPrivateFieldSet(this, _times, (_this$times = _classPrivateFieldGet(this, _times), --_this$times)))) {
resetClasses(this.marker._icon, _classPrivateFieldGet(this, _classes));
if (this.marker._shadow && this.bouncingOptions.shadowAngle) {
resetClasses(this.marker._shadow, _classPrivateFieldGet(this, _classes));
}
} else {
_classPrivateFieldGet(this, _classes).forEach(function (className) {
_leaflet.DomUtil.removeClass(_this2.marker._icon, className);
if (_this2.marker._shadow) {
_leaflet.DomUtil.removeClass(_this2.marker._shadow, className);
}
});
this.bouncingAnimationPlaying = false;
this.marker.fire('bounceend');
}
}
}
}
}, {
key: "resetStyles",
value: function resetStyles(marker) {
var _this$marker$getIcon,
_this$marker$getIcon$,
_this$marker,
_this$marker$_iconObj,
_this$marker$_iconObj2,
_this3 = this;
this.marker = marker;
this.iconStyles = _Styles["default"].ofMarker(marker);
if (marker._shadow) {
this.shadowStyles = _Styles["default"].parse(marker._shadow.style.cssText);
}
var iconHeight = ((_this$marker$getIcon = this.marker.getIcon()) === null || _this$marker$getIcon === void 0 ? void 0 : (_this$marker$getIcon$ = _this$marker$getIcon.options) === null || _this$marker$getIcon$ === void 0 ? void 0 : _this$marker$getIcon$.iconSize[1]) || ((_this$marker = this.marker) === null || _this$marker === void 0 ? void 0 : (_this$marker$_iconObj = _this$marker._iconObj) === null || _this$marker$_iconObj === void 0 ? void 0 : (_this$marker$_iconObj2 = _this$marker$_iconObj.options) === null || _this$marker$_iconObj2 === void 0 ? void 0 : _this$marker$_iconObj2.iconSize[1]);
var iconAnimationParams = BouncingMotionCss3.animationParams(this.position, this.bouncingOptions, iconHeight);
this.iconStyles = this.iconStyles.withStyles(iconAnimationParams);
this.marker._icon.style.cssText = this.iconStyles.toString();
if (this.bouncingAnimationPlaying) {
resetClasses(this.marker._icon, _classPrivateFieldGet(this, _classes));
this.marker._icon.addEventListener('animationend', _classPrivateFieldGet(this, _listener));
}
var _this$bouncingOptions = this.bouncingOptions,
bounceHeight = _this$bouncingOptions.bounceHeight,
contractHeight = _this$bouncingOptions.contractHeight,
shadowAngle = _this$bouncingOptions.shadowAngle;
if (this.marker._shadow) {
if (shadowAngle) {
var _this$marker$getIcon2, _this$marker$getIcon3;
var _this$position = this.position,
x = _this$position.x,
y = _this$position.y;
var points = (0, _line.calculateLine)(x, y, shadowAngle, bounceHeight + 1);
var _points$bounceHeight = _slicedToArray(points[bounceHeight], 2),
posXJump = _points$bounceHeight[0],
posYJump = _points$bounceHeight[1];
var shadowHeight = (_this$marker$getIcon2 = this.marker.getIcon()) === null || _this$marker$getIcon2 === void 0 ? void 0 : (_this$marker$getIcon3 = _this$marker$getIcon2.options) === null || _this$marker$getIcon3 === void 0 ? void 0 : _this$marker$getIcon3.shadowSize[1];
var shadowScaleContract = BouncingMotionCss3.contractScale(shadowHeight, contractHeight);
this.shadowStyles = this.shadowStyles.withStyles(iconAnimationParams).withStyles({
'--pos-x-jump': "".concat(posXJump, "px"),
'--pos-y-jump': "".concat(posYJump, "px"),
'--scale-contract': shadowScaleContract
});
this.marker._shadow.style.cssText = this.shadowStyles.toString();
if (this.bouncingAnimationPlaying) {
resetClasses(this.marker._shadow, _classPrivateFieldGet(this, _classes));
}
} else {
_classPrivateFieldGet(this, _classes).forEach(function (className) {
_leaflet.DomUtil.removeClass(_this3.marker._shadow, className);
});
}
}
}
}, {
key: "bounce",
value: function bounce() {
var times = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : null;
_classPrivateFieldSet(this, _times, times);
this.isBouncing = true;
if (this.bouncingAnimationPlaying) {
return;
}
_classPrivateFieldSet(this, _eventCounter, 0);
this.bouncingAnimationPlaying = true;
resetClasses(this.marker._icon, _classPrivateFieldGet(this, _classes));
if (this.marker._shadow && this.bouncingOptions.shadowAngle) {
resetClasses(this.marker._shadow, _classPrivateFieldGet(this, _classes));
}
this.marker._icon.addEventListener('animationend', _classPrivateFieldGet(this, _listener));
}
}, {
key: "stopBouncing",
value: function stopBouncing() {
this.isBouncing = false;
}
/**
* Calculates parameters of CSS3 animation of bouncing.
*
* @param position {Point} marker current position on the map canvas
* @param bouncingOptions {BouncingOptions} options of bouncing animation
* @param height {number} icons height
* @return {object} CSS3 animation parameters
*/
}], [{
key: "animationParams",
value: function animationParams(position, bouncingOptions, height) {
var x = position.x,
y = position.y;
var bounceHeight = bouncingOptions.bounceHeight,
contractHeight = bouncingOptions.contractHeight,
bounceSpeed = bouncingOptions.bounceSpeed,
contractSpeed = bouncingOptions.contractSpeed;
var scaleContract = BouncingMotionCss3.contractScale(height, contractHeight);
var durationJump = BouncingMotionCss3.calculateDuration(bounceHeight, bounceSpeed);
var durationContract = BouncingMotionCss3.calculateDuration(contractHeight, contractSpeed);
var delays = [0, durationJump, durationJump * 2, durationJump * 2 + durationContract];
return {
'--pos-x': "".concat(x, "px"),
'--pos-y': "".concat(y, "px"),
'--pos-y-jump': "".concat(y - bounceHeight, "px"),
'--pos-y-contract': "".concat(y + contractHeight, "px"),
'--scale-contract': scaleContract,
'--duration-jump': "".concat(durationJump, "ms"),
'--duration-contract': "".concat(durationContract, "ms"),
'--delays': "0ms, ".concat(delays[1], "ms, ").concat(delays[2], "ms, ").concat(delays[3], "ms")
};
}
/**
* Calculates scale of contracting.
*
* @param {number} height original height
* @param {number} contractHeight how much it must contract
* @return {number} contracting scale between 0 and 1
*/
}, {
key: "contractScale",
value: function contractScale(height, contractHeight) {
return (height - contractHeight) / height;
}
/**
* Calculates duration of animation.
*
* @param height {number} height of movement or resizing (px)
* @param speed {number} speed coefficient
*
* @return {number} duration of animation (ms)
*/
}, {
key: "calculateDuration",
value: function calculateDuration(height, speed) {
var duration = Math.round(speed * speedCoefficient);
var i = height;
while (--i) {
duration += Math.round(speed / (height - i));
}
return duration;
}
}]);
return BouncingMotionCss3;
}();
exports["default"] = BouncingMotionCss3;

View File

@ -0,0 +1,267 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports["default"] = void 0;
var _BouncingMotion2 = _interopRequireDefault(require("./BouncingMotion.js"));
var _line = require("./line.js");
function _interopRequireDefault(obj) {
return obj && obj.__esModule ? obj : {"default": obj};
}
function _typeof(obj) {
"@babel/helpers - typeof";
if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") {
_typeof = function _typeof(obj) {
return typeof obj;
};
} else {
_typeof = function _typeof(obj) {
return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj;
};
}
return _typeof(obj);
}
function _classCallCheck(instance, Constructor) {
if (!(instance instanceof Constructor)) {
throw new TypeError("Cannot call a class as a function");
}
}
function _defineProperties(target, props) {
for (var i = 0; i < props.length; i++) {
var descriptor = props[i];
descriptor.enumerable = descriptor.enumerable || false;
descriptor.configurable = true;
if ("value" in descriptor) descriptor.writable = true;
Object.defineProperty(target, descriptor.key, descriptor);
}
}
function _createClass(Constructor, protoProps, staticProps) {
if (protoProps) _defineProperties(Constructor.prototype, protoProps);
if (staticProps) _defineProperties(Constructor, staticProps);
return Constructor;
}
function _get(target, property, receiver) {
if (typeof Reflect !== "undefined" && Reflect.get) {
_get = Reflect.get;
} else {
_get = function _get(target, property, receiver) {
var base = _superPropBase(target, property);
if (!base) return;
var desc = Object.getOwnPropertyDescriptor(base, property);
if (desc.get) {
return desc.get.call(receiver);
}
return desc.value;
};
}
return _get(target, property, receiver || target);
}
function _superPropBase(object, property) {
while (!Object.prototype.hasOwnProperty.call(object, property)) {
object = _getPrototypeOf(object);
if (object === null) break;
}
return object;
}
function _inherits(subClass, superClass) {
if (typeof superClass !== "function" && superClass !== null) {
throw new TypeError("Super expression must either be null or a function");
}
subClass.prototype = Object.create(superClass && superClass.prototype, {
constructor: {
value: subClass,
writable: true,
configurable: true
}
});
if (superClass) _setPrototypeOf(subClass, superClass);
}
function _setPrototypeOf(o, p) {
_setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(o, p) {
o.__proto__ = p;
return o;
};
return _setPrototypeOf(o, p);
}
function _createSuper(Derived) {
var hasNativeReflectConstruct = _isNativeReflectConstruct();
return function _createSuperInternal() {
var Super = _getPrototypeOf(Derived), result;
if (hasNativeReflectConstruct) {
var NewTarget = _getPrototypeOf(this).constructor;
result = Reflect.construct(Super, arguments, NewTarget);
} else {
result = Super.apply(this, arguments);
}
return _possibleConstructorReturn(this, result);
};
}
function _possibleConstructorReturn(self, call) {
if (call && (_typeof(call) === "object" || typeof call === "function")) {
return call;
}
return _assertThisInitialized(self);
}
function _assertThisInitialized(self) {
if (self === void 0) {
throw new ReferenceError("this hasn't been initialised - super() hasn't been called");
}
return self;
}
function _isNativeReflectConstruct() {
if (typeof Reflect === "undefined" || !Reflect.construct) return false;
if (Reflect.construct.sham) return false;
if (typeof Proxy === "function") return true;
try {
Date.prototype.toString.call(Reflect.construct(Date, [], function () {
}));
return true;
} catch (e) {
return false;
}
}
function _getPrototypeOf(o) {
_getPrototypeOf = Object.setPrototypeOf ? Object.getPrototypeOf : function _getPrototypeOf(o) {
return o.__proto__ || Object.getPrototypeOf(o);
};
return _getPrototypeOf(o);
}
function _defineProperty(obj, key, value) {
if (key in obj) {
Object.defineProperty(obj, key, {value: value, enumerable: true, configurable: true, writable: true});
} else {
obj[key] = value;
}
return obj;
}
var BouncingMotionSimple = /*#__PURE__*/function (_BouncingMotion) {
_inherits(BouncingMotionSimple, _BouncingMotion);
var _super = _createSuper(BouncingMotionSimple);
/**
* Constructor.
*
* @param marker {Marker} marker
* @param position {Point} marker current position on the map canvas
* @param bouncingOptions {BouncingOptions} options of bouncing animation
*/
function BouncingMotionSimple(marker, position, bouncingOptions) {
var _this;
_classCallCheck(this, BouncingMotionSimple);
_this = _super.call(this, marker, position, bouncingOptions);
_defineProperty(_assertThisInitialized(_this), "iconMovePoints", void 0);
_defineProperty(_assertThisInitialized(_this), "shadowMovePoints", void 0);
_this.recalculateMotion(position);
return _this;
}
_createClass(BouncingMotionSimple, [{
key: "recalculateMotion",
value: function recalculateMotion(position) {
_get(_getPrototypeOf(BouncingMotionSimple.prototype), "recalculateMotion", this).call(this, position);
var x = position.x,
y = position.y;
var _this$bouncingOptions = this.bouncingOptions,
bounceHeight = _this$bouncingOptions.bounceHeight,
shadowAngle = _this$bouncingOptions.shadowAngle;
this.iconMovePoints = BouncingMotionSimple.calculateIconMovePoints(x, y, bounceHeight);
this.shadowMovePoints = BouncingMotionSimple.calculateShadowMovePoints(x, y, bounceHeight, shadowAngle);
}
}, {
key: "makeMoveStep",
value: function makeMoveStep(step) {
_get(_getPrototypeOf(BouncingMotionSimple.prototype), "makeMoveStep", this).call(this, step);
this.marker._icon.style.left = this.iconMovePoints[step][0] + 'px';
this.marker._icon.style.top = this.iconMovePoints[step][1] + 'px';
if (this.marker._shadow) {
this.marker._shadow.style.left = this.shadowMovePoints[step][0] + 'px';
this.marker._shadow.style.top = this.shadowMovePoints[step][1] + 'px';
}
}
/**
* Returns calculated array of points for icon movement. Used to animate markers in browsers
* that doesn't support 'transform' attribute.
*
* @param x {number} x coordinate of original position of the marker
* @param y {number} y coordinate of original position of the marker
* @param bounceHeight {number} height of bouncing (px)
*
* @return {[number, number][]} array of points
*/
}], [{
key: "calculateIconMovePoints",
value: function calculateIconMovePoints(x, y, bounceHeight) {
var deltaHeight = bounceHeight + 1;
var points = []; // Use fast inverse while loop to fill the array
while (deltaHeight--) {
points[deltaHeight] = [x, y - deltaHeight];
}
return points;
}
/**
* Returns calculated array of points for shadow movement. Used to animate markers in browsers
* that doesn't support 'transform' attribute.
*
* @param x {number} x coordinate of original position of the marker
* @param y {number} y coordinate of original position of the marker
* @param bounceHeight {number} height of bouncing (px)
* @param angle {number} shadow inclination angle, if null shadow don't moves from it's initial
* position (radians)
*
* @return {[number, number][]} array of points
*/
}, {
key: "calculateShadowMovePoints",
value: function calculateShadowMovePoints(x, y, bounceHeight, angle) {
if (angle != null) {
// important: 0 is not null
return (0, _line.calculateLine)(x, y, angle, bounceHeight + 1);
} else {
var points = [];
for (var i = 0; i <= bounceHeight; i++) {
points[i] = [x, y];
}
return points;
}
}
}]);
return BouncingMotionSimple;
}(_BouncingMotion2["default"]);
exports["default"] = BouncingMotionSimple;

View File

@ -0,0 +1,105 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports["default"] = void 0;
function _classCallCheck(instance, Constructor) {
if (!(instance instanceof Constructor)) {
throw new TypeError("Cannot call a class as a function");
}
}
function _defineProperties(target, props) {
for (var i = 0; i < props.length; i++) {
var descriptor = props[i];
descriptor.enumerable = descriptor.enumerable || false;
descriptor.configurable = true;
if ("value" in descriptor) descriptor.writable = true;
Object.defineProperty(target, descriptor.key, descriptor);
}
}
function _createClass(Constructor, protoProps, staticProps) {
if (protoProps) _defineProperties(Constructor.prototype, protoProps);
if (staticProps) _defineProperties(Constructor, staticProps);
Object.defineProperty(Constructor, "prototype", {writable: false});
return Constructor;
}
function _defineProperty(obj, key, value) {
if (key in obj) {
Object.defineProperty(obj, key, {value: value, enumerable: true, configurable: true, writable: true});
} else {
obj[key] = value;
}
return obj;
}
var BouncingOptions = /*#__PURE__*/function () {
/**
* How high marker can bounce (px)
* @type {number}
*/
/**
* How much marker can contract (px)
* @type {number}
*/
/**
* Bouncing speed coefficient
* @type {number}
*/
/**
* Contracting speed coefficient
* @type {number}
*/
/**
* Shadow inclination angle(radians); null to cancel shadow movement
* @type {number}
*/
/**
* Activate contract animation
* @type {boolean}
*/
/**
* Many markers can bounce in the same time
* @type {boolean}
*/
function BouncingOptions(options) {
_classCallCheck(this, BouncingOptions);
_defineProperty(this, "bounceHeight", 15);
_defineProperty(this, "contractHeight", 12);
_defineProperty(this, "bounceSpeed", 52);
_defineProperty(this, "contractSpeed", 52);
_defineProperty(this, "shadowAngle", -Math.PI / 4);
_defineProperty(this, "elastic", true);
_defineProperty(this, "exclusive", false);
options && Object.assign(this, options);
}
_createClass(BouncingOptions, [{
key: "override",
value: function override(options) {
return Object.assign(new BouncingOptions(this), options);
}
}]);
return BouncingOptions;
}();
exports["default"] = BouncingOptions;

View File

@ -0,0 +1,65 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports["default"] = void 0;
function _classCallCheck(instance, Constructor) {
if (!(instance instanceof Constructor)) {
throw new TypeError("Cannot call a class as a function");
}
}
function _defineProperties(target, props) {
for (var i = 0; i < props.length; i++) {
var descriptor = props[i];
descriptor.enumerable = descriptor.enumerable || false;
descriptor.configurable = true;
if ("value" in descriptor) descriptor.writable = true;
Object.defineProperty(target, descriptor.key, descriptor);
}
}
function _createClass(Constructor, protoProps, staticProps) {
if (protoProps) _defineProperties(Constructor.prototype, protoProps);
if (staticProps) _defineProperties(Constructor, staticProps);
return Constructor;
}
function _defineProperty(obj, key, value) {
if (key in obj) {
Object.defineProperty(obj, key, {value: value, enumerable: true, configurable: true, writable: true});
} else {
obj[key] = value;
}
return obj;
}
var Cache = /*#__PURE__*/function () {
function Cache() {
_classCallCheck(this, Cache);
_defineProperty(this, "cache", {});
}
_createClass(Cache, [{
key: "get",
/**
* If item with supplied {@code key} is present in cache, returns it, otherwise executes
* {@code supplier} function and caches the result.
*
* @param key {String} key of the cache
* @param supplier {function} item supplier
* @return {Object} item
*/
value: function get(key, supplier) {
return this.cache[key] || (this.cache[key] = supplier.apply());
}
}]);
return Cache;
}();
exports["default"] = Cache;

Some files were not shown because too many files have changed in this diff Show More