91 lines
4.5 KiB
Markdown
91 lines
4.5 KiB
Markdown
# Shark Attack Fantasy Game — Full-Stack Pattern
|
|
|
|
## Architecture
|
|
```
|
|
iOS Safari → Cloudflare → Caddy (shark.iamgmb.com) → FastAPI (port 8083)
|
|
├── /api/* (auth, leagues, draft, scores)
|
|
└── /* (static frontend via FileResponse catch-all)
|
|
```
|
|
|
|
## Domain / DNS setup
|
|
- Domain: `shark.iamgmb.com` (Cloudflare zone f1fb2d357b8ff0fab54c5856130ec9ed)
|
|
- Points to Core's IP (152.53.192.33) via A record (not CNAME — CNAME flattens fail for cross-account zones)
|
|
- DNS-only initially, switch to proxied after TLS cert provisions
|
|
- Caddy full-proxy pattern: EVERYTHING routes through the backend on 8083
|
|
|
|
## Backend features learned
|
|
- **FastAPI monolithic server.py** — ~980 lines, no routers, all endpoints in one file
|
|
- **SQLite** with WAL mode + foreign keys
|
|
- **Auth:** bcrypt password hashing, PyJWT tokens, 30-day expiry
|
|
- **JWT gotcha:** `datetime.utcnow()` vs `datetime.now(timezone.utc)` — PyJWT expects naive UTC
|
|
- **Registration response must include `display_name`** — frontend uses it immediately
|
|
- **Snake draft logic:** round 1: 1→N, round 2: N→1, round 3: 1→N. Track via round number: odd rounds are forward, even are reverse
|
|
- **Scoring engine:** events table + materialized user_scores table recalculated on each insert
|
|
- **Point values:** sighting=2, bite=5, fatality=10
|
|
|
|
## Draft regulation
|
|
- **Minimum 2 members** to start a draft (server enforces)
|
|
- **Commissioner-only** start draft endpoint
|
|
- **Turn enforcement by draft_order** — server rejects out-of-turn picks with 403
|
|
- **Region locking** — duplicate picks return 409
|
|
|
|
## 32 Regions with ISAF-based data
|
|
|
|
Top 5 regions by 10-year sighting count:
|
|
| Region | Sightings | Bites | Fatalities |
|
|
|--------|-----------|-------|-----------|
|
|
| Australia East | 87 | 28 | 6 |
|
|
| Florida East Coast | 85 | 25 | 1 |
|
|
| Hawaii | 47 | 12 | 2 |
|
|
| Australia West | 42 | 15 | 8 |
|
|
| Caribbean | 40 | 10 | 2 |
|
|
|
|
Full data saved to `/root/shark-game/references/shark-incident-data-10yr.md` sourced from ISAF year-by-year reports.
|
|
|
|
## Caddy full-proxy pattern
|
|
When the backend serves BOTH API and static files, use:
|
|
```
|
|
shark.iamgmb.com {
|
|
reverse_proxy 127.0.0.1:8083
|
|
}
|
|
```
|
|
Backend catch-all route:
|
|
```python
|
|
@app.api_route("/{path_name:path}", methods=["GET"])
|
|
async def serve_frontend(path_name: str):
|
|
if path_name.startswith("api/"):
|
|
return {"error": "not found"}
|
|
file_path = os.path.join(STATIC_DIR, path_name)
|
|
if os.path.isfile(file_path):
|
|
return FileResponse(file_path)
|
|
return FileResponse(os.path.join(STATIC_DIR, "index.html"))
|
|
```
|
|
|
|
## Frontend-JWT integration pattern
|
|
- Token stored in `localStorage` as `shark_token`, user as `shark_user`
|
|
- Auth guard on each page: `if (!getToken()) window.location.href = 'index.html';`
|
|
- API base is empty string (same-origin)
|
|
- Registration sends: `{email, password, display_name, phone?}`
|
|
- After auth, checks `/api/leagues` to determine next screen
|
|
- Pages: index.html (landing/register/login/league-mgmt), draft-room.html (draft/leaderboard/shark-feed/learn), league.html (settings/invite)
|
|
|
|
## Multi-tab draft room pattern
|
|
The draft room has 6 tabs:
|
|
1. DRAFT — Map view (Leaflet/OSM) | List view toggle
|
|
2. MY TEAM — User's drafted regions + cumulative score
|
|
3. STANDINGS — Leaderboard (all players ranked) + daily breakdown
|
|
4. 🦈 LEARN — Species guide, shark facts, safety tips, conservation
|
|
5. LEAGUE — Invite code, member list, start draft button (commissioner)
|
|
6. 🦈 SHARK FEED — Upcoming shark media (Shark Week, movies) + general shark news
|
|
|
|
## AI scraper pattern
|
|
- Daily cron: Firecrawl search → admin-ai classification → pending-scores.json
|
|
- Region matching via keyword/fuzzy matching of article text/location
|
|
- Dedup via processed-urls.json
|
|
|
|
## Common pitfalls
|
|
- **Missing `.hidden` CSS class** — New standalone pages often define `.hidden` via JS but forget the CSS rule `.hidden { display: none !important; }` in `<style>`. Index pages get it; sibling pages don't. Always add it to every new page.
|
|
- **Registration returns `display_name: null`** — The `/api/auth/register` endpoint must pass `display_name=req.display_name` in the response, not just write it to the DB. Login endpoint does this correctly (reads from DB); register endpoint must be patched to match.
|
|
- **Cross-account DNS** — A records work where CNAMEs silently fail for Cloudflare cross-account setups.
|
|
- **Serve both frontend and API from one backend** — Full-proxy through FastAPI + FileResponse catch-all avoids Caddy routing conflicts.
|