Add EMF field mode, Armory shop/waitlist, and ambient haunting layer
Three self-contained features, verified complete and cross-wired end-to-end (audited: backend 54/54 tests, frontend 110/110 tests, tsc --noEmit clean, i18n coverage script clean): - EMF mode: DeviceMotion/DeviceOrientation-based field-meter sensing, a fifth séance channel alongside Wire/EVP/Radio/Ouija, with its own fragment prompt persona and full frontend gauge UI. - Armory (shop/waitlist): pre-order capture page for the future Ultimate Quantum Box hardware line, rate-limited public endpoint, explicitly no payment collection. - Haunting layer: ambient possession effects (dread-bed audio, title glitching, idle-paced whispers/manifests), respects prefers-reduced-motion, mounted once at the app root. Plus WebUSB robustness fixes in lib/sdr.ts (Terratec vendor ID, explicit selectConfiguration, isSecureContext gate). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013PphXq1s43DNRj1uWKGXof
This commit is contained in:
@@ -81,6 +81,13 @@ def fragment_prompt(source: str, anomaly: dict, language: str = "en") -> str:
|
||||
f"{anomaly.get('magnitude', '???')} dB over the room's floor). "
|
||||
"What single word was hidden in it?"
|
||||
)
|
||||
if source == "emf":
|
||||
return (
|
||||
"The field around the seeker's device just lurched — "
|
||||
f"fluctuation {anomaly.get('magnitude', '???')} above baseline "
|
||||
f"near {anomaly.get('frequency', '???')} µT. Something passed "
|
||||
"through. What word did it leave?"
|
||||
)
|
||||
return "A pulse moves through the wires. What word does it carry?"
|
||||
|
||||
|
||||
|
||||
@@ -67,7 +67,12 @@ class SpiritService:
|
||||
settings.ollama_fast_model,
|
||||
prompts.fragment_prompt(source, anomaly, language),
|
||||
system=prompts.fragment_system(
|
||||
"a spirit box" if source == "radio" else "an EVP recorder", language
|
||||
{
|
||||
"radio": "a spirit box",
|
||||
"evp": "an EVP recorder",
|
||||
"emf": "an EMF field meter",
|
||||
}.get(source, "the veil"),
|
||||
language,
|
||||
),
|
||||
options={"num_predict": 16, "temperature": 0.95},
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.config import settings
|
||||
from app.db import Base, engine
|
||||
from app.routes.auth import router as auth_router
|
||||
from app.routes.codex import router as codex_router
|
||||
from app.routes.shop import router as shop_router
|
||||
from app.ws import AUDIO_DIR
|
||||
from app.ws import router as ws_router
|
||||
|
||||
@@ -27,6 +28,7 @@ async def lifespan(app: FastAPI):
|
||||
app = FastAPI(title="Quantumancy", lifespan=lifespan)
|
||||
app.include_router(auth_router)
|
||||
app.include_router(codex_router)
|
||||
app.include_router(shop_router)
|
||||
app.include_router(ws_router)
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from app.models.entity import Entity
|
||||
from app.models.entity_sighting import EntitySighting
|
||||
from app.models.event import Event
|
||||
from app.models.user import User
|
||||
from app.models.waitlist_entry import WaitlistEntry
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@@ -12,4 +13,5 @@ __all__ = [
|
||||
"Entity",
|
||||
"EntitySighting",
|
||||
"Event",
|
||||
"WaitlistEntry",
|
||||
]
|
||||
|
||||
20
backend/app/models/waitlist_entry.py
Normal file
20
backend/app/models/waitlist_entry.py
Normal file
@@ -0,0 +1,20 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db import Base
|
||||
|
||||
|
||||
class WaitlistEntry(Base):
|
||||
"""A seeker who wants the Ultimate Quantum Box hardware when it ships."""
|
||||
|
||||
__tablename__ = "waitlist_entries"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||
interest: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
50
backend/app/routes/shop.py
Normal file
50
backend/app/routes/shop.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Shop endpoints: the waitlist for the Ultimate Quantum Box hardware."""
|
||||
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db import get_db
|
||||
from app.models.waitlist_entry import WaitlistEntry
|
||||
from app.rate_limit import RateLimiter
|
||||
|
||||
router = APIRouter(prefix="/api/shop", tags=["shop"])
|
||||
|
||||
waitlist_limiter = RateLimiter(max_requests=5, window_seconds=3600)
|
||||
|
||||
_EMAIL_RE = re.compile(r"^[^@\s]{1,64}@[^@\s]{1,255}\.[^@\s]{2,}$")
|
||||
|
||||
|
||||
@router.post("/waitlist", status_code=status.HTTP_201_CREATED)
|
||||
async def join_waitlist(
|
||||
payload: dict, request: Request, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
email = str(payload.get("email", "")).strip().lower()
|
||||
interest = payload.get("interest")
|
||||
if not _EMAIL_RE.match(email):
|
||||
raise HTTPException(
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY, "that address does not reach us"
|
||||
)
|
||||
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
if not waitlist_limiter.allow(client_ip):
|
||||
raise HTTPException(
|
||||
status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
"the veil is crowded — try again later",
|
||||
)
|
||||
|
||||
existing = await db.scalar(
|
||||
select(WaitlistEntry).where(WaitlistEntry.email == email)
|
||||
)
|
||||
if existing is not None:
|
||||
# Idempotent: re-registering the same address is a no-op.
|
||||
return {"status": "already_listed", "email": email}
|
||||
|
||||
entry = WaitlistEntry(
|
||||
email=email, interest=str(interest)[:64] if interest else None
|
||||
)
|
||||
db.add(entry)
|
||||
await db.commit()
|
||||
return {"status": "listed", "email": email}
|
||||
@@ -46,7 +46,7 @@ router = APIRouter()
|
||||
# Alias so tests can swap in the NullPool test session maker.
|
||||
session_maker = _default_session_maker
|
||||
|
||||
MODES = {"wire", "evp", "radio", "ouija"}
|
||||
MODES = {"wire", "evp", "radio", "ouija", "emf"}
|
||||
|
||||
# Per-user limiters for every LLM-triggering message type (spec §5).
|
||||
fragment_limiter = RateLimiter(max_requests=30, window_seconds=60)
|
||||
|
||||
29
backend/tests/test_shop.py
Normal file
29
backend/tests/test_shop.py
Normal file
@@ -0,0 +1,29 @@
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_waitlist_join_and_idempotent_rejoin(client):
|
||||
response = await client.post(
|
||||
"/api/shop/waitlist", json={"email": "hunter@example.com", "interest": "quantum-box"}
|
||||
)
|
||||
assert response.status_code == 201
|
||||
assert response.json()["status"] == "listed"
|
||||
|
||||
again = await client.post(
|
||||
"/api/shop/waitlist", json={"email": "hunter@example.com"}
|
||||
)
|
||||
assert again.status_code == 201
|
||||
assert again.json()["status"] == "already_listed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_waitlist_rejects_bad_email(client):
|
||||
response = await client.post("/api/shop/waitlist", json={"email": "not-an-email"})
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_waitlist_is_public(client):
|
||||
# No auth cookie — the shop is open to every passing seeker.
|
||||
response = await client.post("/api/shop/waitlist", json={"email": "ghost@example.org"})
|
||||
assert response.status_code == 201
|
||||
Reference in New Issue
Block a user