First commit, phased 1 and 2 created but untested

This commit is contained in:
bepis
2026-10-20 07:40:35 +11:00
commit 7a3e408ffc
66 changed files with 5639 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
PORT=3000
NODE_ENV=development
SESSION_SECRET=change-me-to-a-long-random-string
DB_PATH=./data/emberfall.db
+7
View File
@@ -0,0 +1,7 @@
node_modules/
.env
data/*.db
data/*.db-journal
data/*.db-wal
data/*.db-shm
*.log
+64
View File
@@ -0,0 +1,64 @@
# NOTES.md — Project Brief (READ FIRST)
## 1. What you are building
**Emberfall**: a fantasy persistent browser-based game (PBBG). Players register
an account, create a hero, and spend regenerating **Energy** on actions. The
world keeps moving while they are offline (energy regenerates, expeditions
finish). All state lives in a database on the server.
You are the only developer. A human will play-test the result. Working,
simple, and correct beats ambitious and broken.
## 2. Hard constraints (do not violate)
1. **Stack is fixed:** Node.js (>= 20), Express, SQLite via `better-sqlite3`,
`bcryptjs` for password hashing, `express-session` + `better-sqlite3-session-store`
(or `connect-sqlite3`) for sessions, `helmet`, `express-rate-limit`,
`zod` for input validation. Tests with the built-in `node:test` runner.
2. **Frontend is vanilla HTML/CSS/JS.** No React, no build step, no bundler.
Static files in `public/`, talking to the JSON API with `fetch`.
3. **No new dependencies** without logging the reason in `docs/DECISIONS.md`.
4. **The server is the only source of truth.** The client never sends results,
damage, gold, XP, or timestamps. It only sends *intent* ("hunt in zone 2").
5. **No real-time ticks / cron loops for game logic.** Use *lazy evaluation*:
compute energy regen and finished timers when the player next makes a request
(see `docs/GAME_DESIGN.md` §Time).
6. **All SQL uses prepared statements with bound parameters.** Never build SQL
with string concatenation.
7. **Keep files small** (aim < 200 lines). Split by responsibility.
8. **Game rules live in `src/game/` as pure functions** (no DB, no Express).
They take data in and return data out, so they can be unit tested.
9. **Never store plaintext passwords.** Never log passwords or session secrets.
10. **Don't touch what isn't in the current step.** No drive-by refactors.
11. **Re-read NOTES.md and STATUS.md after each phase.** Keep your context fresh.
## 3. How to work (summary — details in docs/WORKFLOW.md)
1. Open `STATUS.md`. Find the first unchecked step in the lowest-numbered phase.
2. Read ONLY the docs that step references.
3. Implement the step in small pieces. Run the app / tests after each piece.
4. When the step's acceptance criteria pass, tick the box in `STATUS.md`,
add a Session Log line, and record any new Known Issues.
5. Stop and re-read `STATUS.md` before starting the next step.
If a doc is ambiguous or contradicts another doc, pick the simplest option,
and write it down in `docs/DECISIONS.md`. Do not stall.
## 4. Priority order when things conflict
Correctness & security > persistence works > game is playable > code tidy > fun extras.
## 5. Glossary
- **Hero / Character:** the player's in-game avatar. One per account (v1).
- **Energy:** action currency. Max 100. Regenerates over real time.
- **Gold:** soft currency. Earned from combat/expeditions, spent in shops.
- **Expedition:** a timed activity; started now, claimed later.
- **Zone:** an area with monsters, level range, and loot.
- **Lazy regen:** computing regen from `energy_updated_at` when needed.
## 6. Out of scope for v1
Real-money payments, email verification, password reset by email, websockets,
mobile apps, 3rd-party OAuth, admin web UI. (Stretch goals are in STATUS.md.)
## 7. Definition of "game is playable" (v1 target)
A new visitor can: register → log in → create a hero → see stats/energy →
hunt monsters → gain XP/gold/loot → level up → equip items → buy/sell in the
shop → start and claim an expedition → see the leaderboard → log out → log back
in and find everything exactly as left (plus offline energy regen).
+23
View File
@@ -0,0 +1,23 @@
# PROMPT.md — Paste this to your local model (human use)
You are an autonomous software engineer building a fantasy PBBG called Emberfall
inside the current project folder. All specifications are in markdown files here.
1. Read `NOTES.md`, then `STATUS.md`. Do not read other docs until a step needs them.
2. Work on the first unchecked step in `STATUS.md`. Read only the docs that phase references.
3. Implement in small pieces. Run the code/tests after each piece.
4. When a step's acceptance criteria are verified, tick it in `STATUS.md`, add a Session Log line, and note any Known Issues.
5. Continue to the next step. Stop only when blocked or when Phase 9 is complete.
Rules: obey the Hard Constraints in NOTES.md. Do not add dependencies without logging in
docs/DECISIONS.md. Do not skip updating STATUS.md. If docs are ambiguous, choose the
simplest option and log it.
Begin with step 0.1.
---
## Tips for the human
- Give the model a fresh context every phase or two: "Read NOTES.md and STATUS.md and continue."
- If it drifts, tell it: "Re-read STATUS.md and WORKFLOW.md, then continue."
- Models do better with a tool-using harness (file read/write + shell). Raw chat works but you'll paste a lot.
- Set a large context window (≥ 32k) in your runner; docs are intentionally split to keep each read small.
+38
View File
@@ -0,0 +1,38 @@
# Emberfall — A Fantasy PBBG
A persistent browser-based game (PBBG): players register, create a hero, spend
regenerating Energy on actions (hunting, expeditions, training), fight monsters,
collect loot, and climb leaderboards. Progress persists between sessions and
continues to "happen" while the player is offline (timers, energy regen).
## For the AI agent building this
**Start with `NOTES.md`, then `STATUS.md`.** Do not read every doc up front.
Open only the docs referenced by the step you are working on.
## Doc map
| File | Read it when... |
|---|---|
| `NOTES.md` | ALWAYS first. Rules, stack, constraints. |
| `STATUS.md` | ALWAYS second. Pick the next unchecked step. Update it when done. |
| `docs/WORKFLOW.md` | You are unsure how to work or what "done" means. |
| `docs/ARCHITECTURE.md` | Creating or moving files; deciding where code goes. |
| `docs/DATABASE.md` | Touching the database or writing migrations. |
| `docs/API.md` | Writing or calling any route. |
| `docs/GAME_DESIGN.md` | Implementing any game mechanic. |
| `docs/BALANCE.md` | Writing formulas, numbers, loot tables, monsters. |
| `docs/SECURITY.md` | Auth, input handling, anything user-facing. |
| `docs/UI_GUIDE.md` | Building pages/CSS/frontend JS. |
| `docs/CODING_STANDARDS.md` | Style, naming, error handling. |
| `docs/TESTING.md` | Writing or running tests. |
| `docs/DECISIONS.md` | Making a choice that deviates from the docs. |
## For the human
```bash
cp .env.example .env
npm install
npm run migrate
npm run seed
npm start # http://localhost:3000
npm test
```
(These scripts are created by the agent in STATUS.md Phase 0.)
+147
View File
@@ -0,0 +1,147 @@
# STATUS.md — Living Progress Tracker
> **Agent rules for this file**
> - Update it at the end of EVERY step. Do not skip.
> - Only tick a box when the step's **Acceptance** line is actually verified.
> - Add problems you could not fix to **Known Issues** instead of hiding them.
> - Keep Session Log entries to one or two lines.
## Current Focus
- **Phase:** 3
- **Step:** 3.1
- **Blocked?** No
- **Last verified working command:** `node -e "createApp"` — Phase 2 complete, app loads
## Project Health
| Item | State |
|---|---|
| App starts (`npm start`) | ✅ works (session interval keeps process alive) |
| Tests pass (`npm test`) | ✅ 30/30 passing (6 auth + 1 health + 23 unit) |
| Migrations run clean on empty DB | ✅ 001_users, 002_characters |
| Can register + login | ✅ verified by tests |
| Data persists across restart | ✅ SQLite-backed sessions |
---
## Phase 0 — Scaffold
- [x] **0.1** `npm init`, install deps from NOTES.md §2, add scripts: `start`, `dev` (node --watch), `test`, `migrate`, `seed`.
*Acceptance:* `npm install` succeeds; scripts exist in package.json.
- [x] **0.2** `src/config/index.js` loads `.env` (use `process.loadEnvFile` or `dotenv`), validates required vars, exports a frozen config object.
*Acceptance:* missing SESSION_SECRET in production throws a clear error.
- [x] **0.3** `src/db/index.js` opens SQLite (WAL mode, foreign_keys ON) and exports the handle. `scripts/migrate.js` runs numbered `.sql` files from `src/db/migrations/` in order and records them in a `_migrations` table.
*Acceptance:* `npm run migrate` twice in a row is safe (second run does nothing).
- [x] **0.4** `src/app.js` (builds Express app, no listen) + `src/server.js` (listens). Add helmet, JSON body parser (limit 10kb), static `public/`, `GET /api/health` → `{ok:true}`, central error handler (see CODING_STANDARDS).
*Acceptance:* `curl localhost:3000/api/health` returns `{"ok":true}`.
- [x] **0.5** First test: health endpoint via `node:test` + `fetch` against `app` on an ephemeral port (see TESTING.md).
*Acceptance:* `npm test` passes.
## Phase 1 — Accounts & Sessions (docs: DATABASE, API, SECURITY)
- [x] **1.1** Migration `001_users.sql` (users table).
- [x] **1.2** `src/services/authService.js`: `register`, `verifyLogin`. bcryptjs cost 10–12. Case-insensitive unique username.
- [x] **1.3** Session middleware (httpOnly, sameSite=lax, secure in production, SQLite-backed store) + `requireAuth` middleware.
- [x] **1.4** Routes: `POST /api/auth/register`, `POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/auth/me`. Zod validation. Rate limit login/register.
- [x] **1.5** Pages `public/login.html`, `public/register.html` + `public/js/auth.js`.
- [x] **1.6** Tests: register ok, duplicate username, bad password, login ok/fail, `me` requires session, logout clears session.
*Acceptance (phase):* can register, log in, refresh page and stay logged in, restart server and stay logged in, log out.
## Phase 2 — Hero Creation & Profile (docs: GAME_DESIGN §Classes, DATABASE, BALANCE)
- [x] **2.1** Migration `002_characters.sql`.
- [x] **2.2** `src/game/classes.js` (class base stats) + `src/game/stats.js` (derived stats). Unit tests.
- [x] **2.3** `POST /api/character` (create; one per user), `GET /api/character` (full profile incl. derived stats and current energy).
- [x] **2.4** `public/create-hero.html`, `public/game.html` (dashboard shell with stats panel).
*Acceptance:* new user is redirected to create-hero; after creation, dashboard shows stats.
## Phase 3 — Energy & Lazy Time (docs: GAME_DESIGN §Time, BALANCE)
- [x] **3.1** `src/game/energy.js`: pure `computeEnergy(stored, updatedAtMs, nowMs, cfg)` → `{energy, updatedAtMs}`. Unit tests incl. cap, partial intervals, clock going backwards.
- [x] **3.2** `characterService.spendEnergy(characterId, cost)` runs in a transaction; rejects when insufficient.
- [ ] **3.3** Dashboard shows energy bar + "next +1 in mm:ss" countdown (client-side countdown only; server remains authoritative).
*Acceptance:* set `energy_updated_at` back in DB by 30 min → refresh shows regen applied.
## Phase 4 — Combat (Hunting) (docs: GAME_DESIGN §Combat, BALANCE)
- [ ] **4.1** Migrations `003_content.sql` (zones, monsters, items) + seed files with ≥ 3 zones, ≥ 9 monsters, ≥ 20 items (see BALANCE).
- [ ] **4.2** `src/game/combat.js`: pure `resolveCombat(hero, monster, rng)` → `{winner, rounds[], hpRemaining}`. Seedable RNG in `src/utils/rng.js`. Unit tests with fixed seed.
- [ ] **4.3** `src/game/rewards.js`: XP, gold, loot roll. `src/game/leveling.js`: XP curve, level-up, stat points.
- [ ] **4.4** `POST /api/hunt { zoneId }`: spend energy → pick monster → resolve → apply rewards/penalty → write `combat_log` → return summary + round log. All inside one DB transaction.
- [ ] **4.5** UI: zone list, "Hunt" button, combat log display, result summary, level-up message.
*Acceptance:* hunting repeatedly drains energy, awards XP/gold, levels the hero, and persists.
## Phase 5 — Inventory & Equipment (docs: GAME_DESIGN §Items, DATABASE)
- [ ] **5.1** Migration `004_inventory.sql`.
- [ ] **5.2** Services + routes: list inventory, equip, unequip (slots: weapon, armor, trinket). Equipment modifies derived stats.
- [ ] **5.3** Stackable consumables: `POST /api/inventory/use` (health potion heals; energy tonic restores energy, capped).
- [ ] **5.4** UI: inventory grid, equipment panel, tooltips with stats.
## Phase 6 — Shop & Economy (docs: GAME_DESIGN §Economy)
- [ ] **6.1** `GET /api/shop`, `POST /api/shop/buy`, `POST /api/shop/sell`. Sell value = 40% of base price. Gold can never go negative.
- [ ] **6.2** Level-gated shop stock. UI page.
- [ ] **6.3** Tests: buy with insufficient gold, sell equipped item (must be rejected), concurrent double-buy safety (transaction).
## Phase 7 — Expeditions (Offline Progress) (docs: GAME_DESIGN §Time, §Expeditions)
- [ ] **7.1** Migration `005_expeditions.sql`. Expedition definitions in seed data.
- [ ] **7.2** `POST /api/expeditions/start`, `GET /api/expeditions/active`, `POST /api/expeditions/claim`. Only one active expedition per hero. Claim rejected before `ends_at`.
- [ ] **7.3** UI: start panel, progress bar, claim button.
*Acceptance:* start → close browser → return after duration → claim → rewards granted exactly once.
## Phase 8 — Leaderboards & Social-lite (docs: GAME_DESIGN §Leaderboards)
- [ ] **8.1** `GET /api/leaderboard?type=level|gold|kills` top 50, with pagination param.
- [ ] **8.2** Public profile `GET /api/heroes/:name` (no private data).
- [ ] **8.3** UI page.
## Phase 9 — Hardening & Polish (docs: SECURITY, UI_GUIDE, TESTING)
- [ ] **9.1** Walk the SECURITY.md checklist; fix gaps.
- [ ] **9.2** Add `npm run seed:dev` creating a demo account and hero.
- [ ] **9.3** Global error pages / friendly UI error toasts. Loading states.
- [ ] **9.4** Mobile-responsive pass (see UI_GUIDE).
- [ ] **9.5** Final full run of "Definition of playable" from NOTES.md §7. Record results below.
- [ ] **9.6** Update README with real run instructions.
## Stretch Goals (only after Phase 9)
- [ ] S1 Async PvP duels (attack another hero's *snapshot*; cooldown; rating).
- [ ] S2 Skills / talent points spent on level-up.
- [ ] S3 Guilds (create, join, leave, guild board).
- [ ] S4 Player market (list/buy items between players, 5% tax gold sink).
- [ ] S5 Daily login reward + daily quests.
- [ ] S6 In-game chat via polling (no websockets).
- [ ] S7 Achievements.
---
## Known Issues
| # | Area | Description | Severity | Status | Found in step |
|---|------|-------------|----------|--------|---------------|
| _none yet_ | | | | | |
Severity: `blocker` / `major` / `minor` / `cosmetic`. Status: `open` / `fixing` / `wontfix` / `fixed`.
## Technical Debt / TODOs
- _none yet_
## Open Questions (for the human)
- _none yet_
## Decisions Made (short; long form goes in docs/DECISIONS.md)
- _none yet_
## Test Summary
| Suite | Passing | Failing | Last run |
|---|---|---|---|
| unit | 23 | 0 | Phase 2.2 |
| integration | 7 | 0 | Phase 1.6 |
## Session Log
_Format:_ `YYYY-MM-DD | step | what changed | result`
- 2026-10-20 | 0.1 | `npm init -y`, installed express, better-sqlite3, bcryptjs, express-session, better-sqlite3-session-store, helmet, express-rate-limit, zod; added start/dev/test/migrate/seed scripts | verified all 5 scripts present in package.json
- 2026-10-20 | 0.2 | Created `src/config/index.js` with `process.loadEnvFile`, required var validation, frozen export | verified config loads; production check rejects placeholder SECRET
- 2026-10-20 | 0.3 | Created `src/db/index.js` (WAL, FK ON) + `scripts/migrate.js` (runs numbered .sql, tracks in _migrations) | `npm run migrate` twice is safe
- 2026-10-20 | 0.4 | Created `src/utils/errors.js`, `src/app.js` (helmet, JSON parser, static, health, error handler) + `src/server.js` | `curl localhost:3000/api/health` → `{"ok":true}`
- 2026-10-20 | 0.5 | Created `tests/health.test.js` — boots app on ephemeral port, hits /api/health | `npm test` passes (1/1)
- 2026-10-20 | 1.1 | Created `src/db/migrations/001_users.sql` | migration applies clean
- 2026-10-20 | 1.2 | Created `src/services/authService.js`: register + verifyLogin with bcrypt cost 12, timing-safe | service loads
- 2026-10-20 | 1.3 | Created `src/middleware/session.js`: better-sqlite3-session-store, requireAuth | middleware loads
- 2026-10-20 | 1.4 | Created `src/routes/auth.js` with Zod validation, rate limiting; integrated into app.js | app loads
- 2026-10-20 | 1.5 | Created `public/login.html`, `public/register.html`, `public/css/main.css`, `public/js/auth.js`, `login.js`, `register.js` | static files served
- 2026-10-20 | 1.6 | Created `tests/helpers.js` + `tests/auth.test.js` (6 tests) | 6/6 auth tests pass
- 2026-10-20 | 2.1 | Created `src/db/migrations/002_characters.sql` | migration applies clean
- 2026-10-20 | 2.2 | Created `src/game/classes.js`, `src/game/stats.js` + unit tests (23 tests) | 23/23 pass
- 2026-10-20 | 2.3 | Created `src/services/characterService.js`, `src/routes/character.js`; integrated into app.js | app loads
- 2026-10-20 | 2.4 | Created `public/create-hero.html`, `public/game.html`, `public/js/create-hero.js`, `public/js/game.js` | app loads
View File
+87
View File
@@ -0,0 +1,87 @@
# API.md
Base path: `/api`. JSON in, JSON out. Auth via session cookie.
## Response envelope
Success: `{ "ok": true, "data": { ... } }`
Failure: `{ "ok": false, "error": { "code": "INSUFFICIENT_ENERGY", "message": "Not enough energy." } }`
## Status codes
| Code | Use |
|---|---|
| 200 | success |
| 201 | created (register, create hero) |
| 400 | validation failed (`VALIDATION_ERROR`) |
| 401 | not logged in (`UNAUTHENTICATED`) |
| 403 | logged in but not allowed (`FORBIDDEN`, `BANNED`) |
| 404 | not found (`NOT_FOUND`) |
| 409 | conflict (`USERNAME_TAKEN`, `HERO_EXISTS`, `EXPEDITION_ACTIVE`) |
| 422 | rule violation (`INSUFFICIENT_ENERGY`, `INSUFFICIENT_GOLD`, `LEVEL_TOO_LOW`, `NOT_READY`) |
| 429 | rate limited |
| 500 | unexpected (never leak stack traces) |
## Endpoints
### Auth (public unless noted)
| Method | Path | Body | Returns |
|---|---|---|---|
| POST | `/auth/register` | `{username, password}` | `{user:{id,username}}` + sets session |
| POST | `/auth/login` | `{username, password}` | `{user:{id,username}, hasHero}` |
| POST | `/auth/logout` 🔒 | – | `{}` |
| GET | `/auth/me` 🔒 | – | `{user:{id,username}, hasHero}` |
Validation: username 3–20 chars `[A-Za-z0-9_]`; password 8–72 chars.
Login failure always returns the same generic message (`INVALID_CREDENTIALS`) — don't reveal whether the username exists.
### Character 🔒
| Method | Path | Body | Returns |
|---|---|---|---|
| POST | `/character` | `{name, class}` | `{character}` |
| GET | `/character` | – | `{character, derived, equipment, energy:{current,max,nextTickInSec}}` |
| POST | `/character/allocate` | `{str,agi,int,vit}` (points to add) | `{character, derived}` |
| POST | `/character/rest` | – | heals to full for gold cost (see BALANCE) |
### Hunting 🔒
| Method | Path | Body | Returns |
|---|---|---|---|
| GET | `/zones` | – | zones with `unlocked` flag |
| POST | `/hunt` | `{zoneId}` | `{result:{won,rounds[],xp,gold,loot[],leveledUp,newLevel}, character}` |
| GET | `/combat-log?limit=20` | – | recent fights |
### Inventory 🔒
| Method | Path | Body | Returns |
|---|---|---|---|
| GET | `/inventory` | – | `{items:[{inventoryId,item,quantity,equippedSlot}]}` |
| POST | `/inventory/equip` | `{inventoryId}` | `{equipment, derived}` |
| POST | `/inventory/unequip` | `{slot}` | `{equipment, derived}` |
| POST | `/inventory/use` | `{inventoryId}` | `{character, message}` |
### Shop 🔒
| Method | Path | Body | Returns |
|---|---|---|---|
| GET | `/shop` | – | stock list with prices and `canAfford`, `levelOk` |
| POST | `/shop/buy` | `{itemId, quantity}` (1–99) | `{gold, inventory}` |
| POST | `/shop/sell` | `{inventoryId, quantity}` | `{gold, inventory}` |
### Expeditions 🔒
| Method | Path | Body | Returns |
|---|---|---|---|
| GET | `/expeditions` | – | available defs + active one (with `remainingSec`) |
| POST | `/expeditions/start` | `{defId}` | `{expedition}` |
| POST | `/expeditions/claim` | – | `{rewards, character}` |
### Leaderboard & heroes (public read)
| Method | Path | Query | Returns |
|---|---|---|---|
| GET | `/leaderboard` | `type=level|gold|kills`, `page=1` | top 50 per page |
| GET | `/heroes/:name` | – | public profile (name, class, level, kills, equipped item names) |
### Misc
| GET | `/health` | – | `{ok:true}` |
## Rules for every route
1. Validate with zod; on failure → 400 `VALIDATION_ERROR` with field messages.
2. Never trust client-supplied ids for ownership: always scope by `req.session.userId` → character.
3. Responses never include `password_hash` or session data.
4. State-changing endpoints are POST. GET must never modify state.
5. Return the *updated* character where relevant so the UI can refresh without a second call.
+91
View File
@@ -0,0 +1,91 @@
# ARCHITECTURE.md
## Layering (strict, one-way)
```
routes → services → game (pure rules)
↓ ↓
middleware db (prepared statements)
```
- **routes/**: parse + validate request, call a service, shape the response. No business logic. No SQL.
- **services/**: orchestrate. Open transactions, read/write DB, call `game/` functions, return plain objects.
- **game/**: pure functions. No `require` of db/express. Deterministic given inputs (RNG is passed in).
- **db/**: connection, migrations, seeds. Only services import `db`.
- **middleware/**: auth guard, rate limiting, error handler, request validation helper.
- **utils/**: tiny helpers (rng, time, ids, logger).
Dependencies only point downward. `game/` never imports from `services/`.
## Folder structure
```
emberfall/
├── NOTES.md STATUS.md README.md
├── package.json .env .env.example .gitignore
├── docs/ # specs (this folder)
├── data/ # SQLite file lives here (gitignored)
├── scripts/
│ ├── migrate.js # runs src/db/migrations/*.sql in order
│ ├── seed.js # runs src/db/seeds/*.js (idempotent)
│ └── seed-dev.js # demo account + hero
├── src/
│ ├── server.js # listen()
│ ├── app.js # builds express app (exported for tests)
│ ├── config/index.js
│ ├── db/
│ │ ├── index.js # better-sqlite3 handle
│ │ ├── migrations/ # 001_users.sql, 002_characters.sql ...
│ │ └── seeds/ # zones.js, monsters.js, items.js, expeditions.js
│ ├── middleware/
│ │ ├── requireAuth.js
│ │ ├── rateLimiters.js
│ │ ├── validate.js # zod wrapper
│ │ └── errorHandler.js
│ ├── routes/
│ │ ├── index.js # mounts all routers under /api
│ │ ├── auth.js character.js hunt.js inventory.js
│ │ ├── shop.js expeditions.js leaderboard.js heroes.js
│ ├── services/
│ │ ├── authService.js characterService.js huntService.js
│ │ ├── inventoryService.js shopService.js
│ │ ├── expeditionService.js leaderboardService.js
│ ├── game/
│ │ ├── constants.js # ALL tunable numbers live here
│ │ ├── classes.js stats.js energy.js combat.js
│ │ ├── rewards.js leveling.js loot.js
│ └── utils/
│ ├── rng.js time.js errors.js logger.js
├── public/
│ ├── index.html login.html register.html
│ ├── create-hero.html game.html shop.html leaderboard.html
│ ├── css/ (base.css, theme.css, layout.css, components.css)
│ ├── js/ (api.js, auth.js, game.js, ui.js, ...)
│ └── img/
└── tests/
├── unit/ # src/game/* and utils
└── integration/ # HTTP-level tests using a temp DB
```
## Module format
Use **CommonJS** (`require` / `module.exports`) everywhere on the server, to keep
tooling simple. Frontend JS uses plain `<script>` tags or ES modules (`type="module"`) — pick one and stay consistent (log in DECISIONS.md).
## Request lifecycle
1. `helmet` → JSON parser → session → static files
2. `/api/*` routers; protected routers use `requireAuth`
3. Route validates body/query with zod
4. Service runs (transaction for anything that writes more than one row)
5. Route sends `{ ok: true, data: ... }`
6. Any thrown `AppError` → `errorHandler` → `{ ok: false, error: {code, message} }`
## Transactions
Any action that spends a resource AND grants a result (hunt, buy, claim) is ONE
`db.transaction(() => { ... })()`. better-sqlite3 is synchronous, so this also
prevents double-spend races from parallel requests.
## Time
All timestamps are **integer milliseconds since epoch (UTC)**, stored as INTEGER.
`src/utils/time.js` exports `now()` so tests can stub it. Never call `Date.now()`
directly in services or game code.
## Config
Gameplay numbers → `src/game/constants.js`. Environment/infra → `src/config/index.js`.
Never hardcode magic numbers inline.
+115
View File
@@ -0,0 +1,115 @@
# BALANCE.md — Formulas & Starter Numbers
All numbers below go in `src/game/constants.js`. Tune there, not inline.
## Energy
```
ENERGY_MAX = 100
ENERGY_REGEN_SECONDS = 180 // +1 per 3 min
ENERGY_TONIC_RESTORE = 25
```
```
computeEnergy(stored, updatedAtMs, nowMs):
if nowMs <= updatedAtMs: return {energy: stored, updatedAtMs}
gained = floor((nowMs - updatedAtMs) / (REGEN_SECONDS*1000))
if stored + gained >= MAX: return {energy: MAX, updatedAtMs: nowMs}
return {energy: stored + gained, updatedAtMs: updatedAtMs + gained*REGEN_SECONDS*1000}
```
(Keep leftover partial interval by advancing the timestamp only by whole ticks.)
## Leveling
```
xpToNext(level) = floor(100 * level^1.5) // L1→2: 100, L2→3: 282, L5→6: 1118, L10→11: 3162
MAX_LEVEL = 50
STAT_POINTS_PER_LEVEL = 3
```
On level-up: `hp_current = maxHp`, loop to handle multiple level-ups from one reward.
## Derived stats
```
maxHp = 50 + vit*10 + level*5 + itemBonusVit*10
primary = STR | INT | AGI (by class)
attack = 5 + primary*2 + level + equipment.bonus_attack
defense = floor(vit*1.5) + equipment.bonus_defense (+10% if Warrior)
speed = 10 + agi
critChance% = min(40, 5 + floor(agi*0.5)) (+8 if Ranger)
critMult = 1.5
```
Equipment `bonus_str/agi/int/vit` are added to the base stats BEFORE the derived formulas.
## Damage
```
mitigation = 100 / (100 + defenderDefense)
raw = attackerAttack * mitigation
variance = rng.range(0.9, 1.1)
dmg = max(1, round(raw * variance * (crit ? critMult : 1)))
```
Mage bonus: if defenderDefense >= 15, multiply `raw` by 1.15.
Monsters crit at a flat 5%.
## Rewards
```
xp = monster.xp * levelDiffMult
gold = rng.int(monster.gold_min, monster.gold_max) * levelDiffMult
levelDiffMult = clamp(1 + 0.1*(monster.level - hero.level), 0.2, 1.5)
```
Loot: for each row in `loot_table` for that monster, roll `rng.float()*100 < chance_pct`.
Max 2 distinct loot drops per fight.
## Defeat penalty
`goldLost = floor(gold * 0.10)`; `hp_current = 1`; `deaths += 1`.
## Rest
`restCost = level * 5` gold → heals to full. Free if hp already full is rejected (`NOTHING_TO_HEAL`).
## Starter monsters (use or tweak)
| Zone | Name | Lv | HP | Atk | Def | Spd | XP | Gold |
|---|---|---|---|---|---|---|---|---|
| Meadows | Giant Rat | 1 | 20 | 6 | 1 | 12 | 8 | 1–3 |
| Meadows | Meadow Wolf | 2 | 32 | 9 | 3 | 16 | 14 | 2–5 |
| Meadows | Goblin Scrapper | 3 | 45 | 12 | 5 | 11 | 22 | 4–9 |
| Forest | Webspinner | 5 | 70 | 18 | 8 | 15 | 40 | 8–16 |
| Forest | Bandit Cutthroat | 6 | 90 | 22 | 12 | 14 | 55 | 12–24 |
| Forest | Mossback Treant | 8 | 150 | 26 | 22 | 7 | 85 | 15–30 |
| Crags | Ember Imp | 10 | 130 | 34 | 15 | 20 | 120 | 25–45 |
| Crags | Stone Golem | 12 | 260 | 38 | 40 | 6 | 170 | 30–60 |
| Crags | Ashfang Drake | 14 | 300 | 50 | 30 | 18 | 260 | 50–100 |
## Starter items (≥ 20 required; this is a minimum seed list)
| Item | Type | Rarity | Lv | Buy | Bonus |
|---|---|---|---|---|---|
| Rusty Sword | weapon | common | 1 | 20 | +3 atk |
| Apprentice Staff | weapon | common | 1 | 20 | +2 int, +1 atk |
| Hunter's Shortbow | weapon | common | 1 | 20 | +2 agi, +1 atk |
| Iron Blade | weapon | uncommon | 5 | 120 | +8 atk, +2 str |
| Oakwood Staff | weapon | uncommon | 5 | 120 | +6 atk, +3 int |
| Yew Longbow | weapon | uncommon | 5 | 120 | +6 atk, +3 agi |
| Emberforged Greatsword | weapon | rare | 10 | 600 | +18 atk, +4 str |
| Leather Jerkin | armor | common | 1 | 25 | +3 def |
| Chainmail Vest | armor | uncommon | 5 | 140 | +9 def, +2 vit |
| Drake-scale Armor | armor | rare | 12 | 700 | +22 def, +4 vit |
| Wooden Charm | trinket | common | 1 | 30 | +1 vit |
| Wolf-fang Pendant | trinket | uncommon | 3 | 90 | +2 agi |
| Ember Ring | trinket | rare | 10 | 450 | +3 str,+3 int |
| Minor Healing Potion | consumable | common | 1 | 15 | effect heal:50 |
| Greater Healing Potion | consumable | uncommon | 8 | 60 | effect heal:150 |
| Energy Tonic | consumable | uncommon | 1 | 80 | effect energy:25 |
| Rat Tail | material | common | 1 | 0 | sell 2g |
| Wolf Pelt | material | common | 2 | 0 | sell 5g |
| Spider Silk | material | uncommon | 5 | 0 | sell 12g |
| Imp Horn | material | uncommon | 10 | 0 | sell 30g |
| Drake Scale | material | rare | 14 | 0 | sell 90g |
Materials with `buy_price = 0` sell at: common 2×level, uncommon 4×level, rare 8×level (round down, min 1).
## Starter loot chances
Common material from its zone's weakest monsters: 40%. Uncommon gear drops: 3–5%.
Rare gear drops: 0.5–1% from top-tier zone monsters. Healing potion: 8% anywhere.
## Expedition starters
| Name | Min Lv | Duration | Energy | XP | Gold |
|---|---|---|---|---|---|
| Scout the Old Road | 1 | 10 min | 10 | 40 | 10–25 |
| Raid the Bandit Camp | 5 | 1 h | 25 | 300 | 80–160 |
| Delve the Hollow | 10 | 4 h | 40 | 1400 | 300–600 |
(Use short durations such as 20–60 s via an env flag `DEV_FAST_TIMERS=1` for testing.)
+45
View File
@@ -0,0 +1,45 @@
# CODING_STANDARDS.md
## Language & style
- Node 20+, CommonJS, `'use strict'` not needed in CJS modules but harmless.
- 2-space indent, semicolons, single quotes, trailing commas in multiline literals.
- `const` by default; `let` only if reassigned; never `var`.
- Names: `camelCase` functions/vars, `PascalCase` classes, `UPPER_SNAKE` constants, `snake_case` DB columns.
- Convert DB `snake_case` rows → `camelCase` objects at the service boundary (one small mapper per entity).
- Functions do one thing and fit on a screen (< 40 lines). Early returns over deep nesting.
- Comments explain *why*, not *what*. Add a one-line header comment to each file describing its purpose.
## Errors
Create `src/utils/errors.js`:
```js
class AppError extends Error {
constructor(code, message, status = 400) { super(message); this.code = code; this.status = status; }
}
```
- Services throw `AppError` for expected failures (`new AppError('INSUFFICIENT_ENERGY','Not enough energy.',422)`).
- Routes use an async wrapper so thrown errors reach `errorHandler`:
```js
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
```
- `errorHandler` maps `AppError` → envelope; `ZodError` → 400 `VALIDATION_ERROR`; anything else → 500 generic message + server log.
## Pure game code
- `src/game/*` exports plain functions; no I/O; no `Date.now()`; no `Math.random()` — take `rng` / `nowMs` params.
- Return new objects instead of mutating inputs.
## Database access
- One prepared statement per query, created once at module load (or lazily cached), not inside loops.
- Wrap multi-write operations in `db.transaction`.
- Never `SELECT *` in services for public output; select what you return.
## Frontend JS
- `public/js/api.js` exports a small `api(method, path, body)` wrapper that handles JSON, error envelope, and 401 → redirect to login.
- DOM updates via `textContent` / `createElement`. No `innerHTML` with dynamic data.
- One JS file per page + shared `api.js` and `ui.js` (toasts, formatting).
- No global state beyond a single `state` object per page.
## Logging
`src/utils/logger.js`: tiny wrapper over `console` with levels; silent in tests unless `LOG=1`.
## Dependencies
Pinned in package.json via `npm install --save-exact`. No dependency without need (NOTES.md §2.3).
+185
View File
@@ -0,0 +1,185 @@
# DATABASE.md
SQLite via `better-sqlite3`. Pragmas at connect: `journal_mode=WAL`, `foreign_keys=ON`, `busy_timeout=5000`.
## Migration rules
- Files in `src/db/migrations/` named `NNN_description.sql` (zero-padded).
- Applied in order; tracked in `_migrations(name TEXT PRIMARY KEY, applied_at INTEGER)`.
- **Never edit an applied migration.** Add a new one.
- Each file runs inside a transaction.
## Conventions
- Primary keys: `id INTEGER PRIMARY KEY AUTOINCREMENT`.
- Timestamps: `INTEGER` ms epoch UTC. Column names end in `_at`.
- Booleans: `INTEGER` 0/1 with `CHECK`.
- Money/stat columns: `INTEGER` with `CHECK (>= 0)` where negatives are illegal.
- Add indexes for every foreign key and every leaderboard sort column.
## Schema (v1)
### 001_users.sql
```sql
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
password_hash TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_login_at INTEGER,
is_banned INTEGER NOT NULL DEFAULT 0 CHECK (is_banned IN (0,1))
);
CREATE UNIQUE INDEX idx_users_username_nocase ON users(username COLLATE NOCASE);
```
### 002_characters.sql
```sql
CREATE TABLE characters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
class TEXT NOT NULL CHECK (class IN ('warrior','mage','ranger')),
level INTEGER NOT NULL DEFAULT 1 CHECK (level >= 1),
xp INTEGER NOT NULL DEFAULT 0 CHECK (xp >= 0),
gold INTEGER NOT NULL DEFAULT 50 CHECK (gold >= 0),
str INTEGER NOT NULL,
agi INTEGER NOT NULL,
int INTEGER NOT NULL,
vit INTEGER NOT NULL,
unspent_points INTEGER NOT NULL DEFAULT 0 CHECK (unspent_points >= 0),
hp_current INTEGER NOT NULL CHECK (hp_current >= 0),
energy INTEGER NOT NULL CHECK (energy >= 0),
energy_updated_at INTEGER NOT NULL,
kills INTEGER NOT NULL DEFAULT 0,
deaths INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX idx_characters_name_nocase ON characters(name COLLATE NOCASE);
CREATE INDEX idx_characters_level ON characters(level DESC, xp DESC);
CREATE INDEX idx_characters_gold ON characters(gold DESC);
CREATE INDEX idx_characters_kills ON characters(kills DESC);
```
### 003_content.sql (static game data, loaded by seeds)
```sql
CREATE TABLE zones (
id INTEGER PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT NOT NULL,
min_level INTEGER NOT NULL,
energy_cost INTEGER NOT NULL
);
CREATE TABLE monsters (
id INTEGER PRIMARY KEY,
zone_id INTEGER NOT NULL REFERENCES zones(id),
name TEXT NOT NULL,
level INTEGER NOT NULL,
hp INTEGER NOT NULL,
attack INTEGER NOT NULL,
defense INTEGER NOT NULL,
speed INTEGER NOT NULL,
xp INTEGER NOT NULL,
gold_min INTEGER NOT NULL,
gold_max INTEGER NOT NULL,
weight INTEGER NOT NULL DEFAULT 10 -- spawn weight within zone
);
CREATE TABLE items (
id INTEGER PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT NOT NULL,
type TEXT NOT NULL CHECK (type IN ('weapon','armor','trinket','consumable','material')),
rarity TEXT NOT NULL CHECK (rarity IN ('common','uncommon','rare','epic','legendary')),
min_level INTEGER NOT NULL DEFAULT 1,
buy_price INTEGER NOT NULL DEFAULT 0, -- 0 = not sold in shop
stackable INTEGER NOT NULL DEFAULT 0 CHECK (stackable IN (0,1)),
bonus_str INTEGER NOT NULL DEFAULT 0,
bonus_agi INTEGER NOT NULL DEFAULT 0,
bonus_int INTEGER NOT NULL DEFAULT 0,
bonus_vit INTEGER NOT NULL DEFAULT 0,
bonus_attack INTEGER NOT NULL DEFAULT 0,
bonus_defense INTEGER NOT NULL DEFAULT 0,
effect TEXT -- consumables: 'heal:50' | 'energy:25'
);
CREATE TABLE loot_table (
monster_id INTEGER NOT NULL REFERENCES monsters(id),
item_id INTEGER NOT NULL REFERENCES items(id),
chance_pct REAL NOT NULL CHECK (chance_pct > 0 AND chance_pct <= 100),
qty_min INTEGER NOT NULL DEFAULT 1,
qty_max INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (monster_id, item_id)
);
```
Seeds use `INSERT ... ON CONFLICT(id) DO UPDATE` so re-running is safe.
### 004_inventory.sql
```sql
CREATE TABLE inventory (
id INTEGER PRIMARY KEY AUTOINCREMENT,
character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
item_id INTEGER NOT NULL REFERENCES items(id),
quantity INTEGER NOT NULL DEFAULT 1 CHECK (quantity >= 1),
equipped_slot TEXT CHECK (equipped_slot IN ('weapon','armor','trinket')),
acquired_at INTEGER NOT NULL
);
CREATE INDEX idx_inventory_char ON inventory(character_id);
-- one equipped item per slot per character
CREATE UNIQUE INDEX idx_inventory_equipped
ON inventory(character_id, equipped_slot) WHERE equipped_slot IS NOT NULL;
```
Rules: stackable items merge into one row per (character, item). Non-stackable items get one row each.
### 005_expeditions.sql
```sql
CREATE TABLE expedition_defs (
id INTEGER PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT NOT NULL,
min_level INTEGER NOT NULL,
duration_sec INTEGER NOT NULL,
energy_cost INTEGER NOT NULL,
xp_reward INTEGER NOT NULL,
gold_min INTEGER NOT NULL,
gold_max INTEGER NOT NULL
);
CREATE TABLE expeditions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
def_id INTEGER NOT NULL REFERENCES expedition_defs(id),
started_at INTEGER NOT NULL,
ends_at INTEGER NOT NULL,
claimed_at INTEGER -- NULL until claimed
);
-- at most one unclaimed expedition per character
CREATE UNIQUE INDEX idx_one_active_expedition
ON expeditions(character_id) WHERE claimed_at IS NULL;
```
### 006_logs.sql
```sql
CREATE TABLE combat_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
monster_id INTEGER NOT NULL REFERENCES monsters(id),
won INTEGER NOT NULL CHECK (won IN (0,1)),
rounds INTEGER NOT NULL,
xp_gained INTEGER NOT NULL,
gold_gained INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX idx_combat_log_char ON combat_log(character_id, created_at DESC);
CREATE TABLE audit_log ( -- security-relevant events
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
event TEXT NOT NULL, -- 'login_ok','login_fail','register','logout'
ip TEXT,
created_at INTEGER NOT NULL
);
```
### Sessions
Created automatically by the session store package. Don't hand-write that table.
## Stretch-goal tables (do NOT create until needed)
`guilds`, `guild_members`, `market_listings`, `pvp_matches`, `chat_messages`, `achievements`, `daily_rewards`.
+34
View File
@@ -0,0 +1,34 @@
# DECISIONS.md — Architecture Decision Log
Record any choice that deviates from or fills a gap in the docs. Newest at the bottom.
Format:
```
## D-NNN: Title
- Date:
- Context: (what problem / ambiguity)
- Decision: (what you chose)
- Alternatives considered:
- Consequences:
```
## D-001: SQLite + better-sqlite3 as the datastore
- Date: (project start)
- Context: Needs persistent accounts and game state with zero setup for a local build.
- Decision: SQLite file in `data/`, synchronous driver, WAL mode.
- Alternatives considered: Postgres (heavier setup), JSON files (no integrity/transactions).
- Consequences: Single-process deployment; synchronous transactions give simple double-spend protection. Migrate to Postgres only if scaling beyond one server.
## D-002: Lazy time evaluation instead of tick loops
- Date: (project start)
- Context: Game must progress while players are offline.
- Decision: Store timestamps; compute energy/timers on demand.
- Consequences: No background workers, trivially testable with injected clocks.
## D-003: Vanilla frontend, no build step
- Date: (project start)
- Context: Reduce tooling surface area for a local-model build.
- Decision: Static HTML/CSS/JS served by Express.
- Consequences: Slightly more DOM boilerplate; far fewer moving parts.
<!-- Add D-004 onward below -->
+89
View File
@@ -0,0 +1,89 @@
# GAME_DESIGN.md — Emberfall
## Setting & tone
The Ashen Realm: an old kingdom whose great Ember-Throne has gone dark. Monsters
creep out of the wilds, and heroes gather at the border town of **Cindergate** to
rekindle the Throne. Tone: classic high fantasy, slightly wry. Keep flavour text
short (1–2 sentences). Names should feel Tolkien/D&D-ish but original.
## Core loop (target session: 5–10 min, several times a day)
1. Log in → see energy (regenerated while away) and any finished expedition.
2. Spend energy: hunt monsters, or start an expedition.
3. Collect XP, gold, loot → level up → equip/upgrade → unlock harder zones.
4. Spend leftover gold in shop. Check leaderboard. Log out.
Energy limits session length; timers give reasons to return.
## Time (lazy evaluation — IMPORTANT)
- **Energy**: max 100, +1 per 180 s (3 min). Stored as `(energy, energy_updated_at)`.
On any request that needs energy: `computeEnergy()` → write back the new pair.
At max, regen stops (timestamp resets to now so no "banked" regen).
- **Expeditions**: stored with `ends_at`. "Finished" is just `now >= ends_at`.
Nothing runs at that moment; the player claims it later.
- **HP**: stored as `hp_current`. Natural regen: +1% max HP per minute, computed lazily
the same way as energy (add `hp_updated_at` column if you implement it; otherwise
HP fully restores on level-up and via Rest/potions — v1 acceptable).
- No `setInterval` for game logic. Ever.
## Classes (pick one at creation; permanent in v1)
| Class | Fantasy | Starting STR/AGI/INT/VIT | Bonus |
|---|---|---|---|
| Warrior | Armoured frontliner | 8 / 4 / 2 / 8 | +10% defense |
| Mage | Glass-cannon spellcaster | 2 / 4 / 9 / 5 | +15% damage vs. high-defense foes |
| Ranger | Fast skirmisher | 5 / 9 / 3 / 5 | +8% crit chance |
Primary stat for attack: Warrior→STR, Mage→INT, Ranger→AGI.
Each level grants **3 unspent stat points** (player allocates via API).
## Stats
- **STR** physical power, **AGI** speed & crit, **INT** magic power, **VIT** health.
- Derived stats formulas: see BALANCE.md §Derived.
## Combat (auto-resolved, server-side, turn based)
- Hero vs one monster, alternating attacks. Higher `speed` acts first each round.
- Max 20 rounds; if no one dies, the one with higher HP% wins.
- Each hit: damage formula in BALANCE.md, small random variance, crit chance.
- Output a **round-by-round log** (array of `{round, actor, damage, crit, targetHpAfter}`) for the UI to replay.
- Win: XP + gold + loot rolls. Lose: lose 10% of carried gold (min 0), HP set to 1,
no XP; deaths +1. (Never lose items in v1.)
- Hero HP persists between fights (`hp_current`). Hunting with HP < 20% of max is
blocked: `HP_TOO_LOW` (422) → player must Rest or use a potion.
- Combat resolution takes an injected `rng` so tests are deterministic.
## Zones (v1: 3 required, add more if time permits)
| Zone | Level | Energy | Theme |
|---|---|---|---|
| Whispering Meadows | 1+ | 5 | goblins, wolves, giant rats |
| Gloomroot Forest | 5+ | 8 | spiders, bandits, treants |
| Ashfang Crags | 10+ | 12 | drakes, fire imps, stone golems |
Optional: Sunken Catacombs (15+), Emberthrone Ruins (20+, boss).
## Items
Types: `weapon`, `armor`, `trinket` (equippable, one per slot), `consumable`
(stackable), `material` (stackable, sell-only in v1).
Rarity: common → uncommon → rare → epic → legendary (colour-coded in UI).
Items give flat stat/attack/defense bonuses. Class restrictions: none in v1.
Starter gear: new heroes get one common weapon appropriate to class (auto-equipped) and 2 Minor Healing Potions.
## Economy
- Gold sources: monster drops, expeditions, selling items.
- Gold sinks: shop purchases, Rest (heal to full), (stretch) market tax.
- Sell price = 40% of `buy_price` (items with `buy_price` 0 sell for rarity default: see BALANCE).
- Prices must make the 1st hour feel rewarding but not trivialise gear. See BALANCE.
- Gold can never go below 0; enforced by DB CHECK and service checks.
## Expeditions (the "offline" mechanic)
- Choose one of 3–4 definitions (e.g. "Scout the Old Road" 10 min, "Raid the Bandit Camp" 1 h, "Delve the Hollow" 4 h).
- Costs energy up-front. One active at a time. Hero cannot hunt while on expedition? **No** — hunting is still allowed (keeps it simple).
- Claim after `ends_at`: grants XP + gold (+ a loot roll for 1h+ ones). Double-claim impossible (`claimed_at` set inside the same transaction).
## Leaderboards
Top 50 by: level (tie-break xp), gold, kills. Public. Query on indexed columns. Optionally cache 30 s in memory.
## Names & content rules
- Hero names: 3–16 chars `[A-Za-z0-9 '-]`, unique case-insensitive, trimmed, no double spaces.
- Basic profanity blocklist: small array in `src/game/constants.js` (keep it short; log extension as a TODO).
## Balance targets (sanity check when testing)
- Level 1 → 5 in roughly 40–60 hunts (~2–3 energy refills).
- A fresh Level-1 hero should win ≈ 80% of Meadow fights and lose ≈ 90% against a Crag monster.
- No single item should raise power by > 25% at its tier.
+71
View File
@@ -0,0 +1,71 @@
# SECURITY.md
PBBGs are cheat targets. Assume every request is hostile.
## Authentication
- Hash with `bcryptjs`, cost 10–12. Never store or log plaintext.
- Case-insensitive unique usernames (DB index uses `COLLATE NOCASE`).
- Login errors are generic: `INVALID_CREDENTIALS`. Do a dummy hash compare when the user doesn't exist (timing).
- Regenerate the session ID on login (`req.session.regenerate`) to prevent fixation.
- Logout destroys the session server-side and clears the cookie.
- Banned users (`is_banned=1`) are rejected at login and in `requireAuth`.
## Sessions / cookies
- `httpOnly: true`, `sameSite: 'lax'`, `secure: true` when `NODE_ENV=production`.
- `maxAge` 7 days, rolling. Secret from `SESSION_SECRET` env (refuse to boot in production without it).
- Store sessions in SQLite, not memory (so restarts don't log everyone out).
## CSRF
Cookie-based sessions + state-changing POSTs ⇒ CSRF matters.
- `sameSite=lax` blocks most cross-site POSTs.
- Additionally require header `Content-Type: application/json` on all POSTs and reject others (browsers can't send cross-site JSON without CORS preflight). **Do not enable CORS.**
## Input validation
- Validate every body/query/param with zod. Reject unknown keys (`.strict()`).
- Clamp numerics (quantities 1–99). Reject NaN/negative/float where ints are expected.
- Never render user-supplied text with `innerHTML`. Use `textContent`. (Usernames and hero names are user-supplied!)
## Anti-cheat / game integrity
- Server decides all outcomes; client sends only intent.
- Every multi-step state change is one DB transaction (spend + grant).
- Ownership: derive the character from the session — never accept `characterId` from the client.
- Check `inventory.character_id = ?` on every inventory operation.
- Spam: rate-limit actions (e.g., 30 game actions / minute / user) — energy is the real limit, this is a safety net.
- Expedition claim: set `claimed_at` and grant rewards in one transaction; the partial unique index prevents duplicates.
- Never trust client clocks. All timing from `utils/time.now()`.
## Rate limiting (`express-rate-limit`)
| Scope | Limit |
|---|---|
| `POST /auth/login` | 10 / 15 min / IP |
| `POST /auth/register` | 5 / hour / IP |
| other `/api` | 120 / min / IP |
## Headers & transport
- `helmet()` defaults. Provide a CSP: `default-src 'self'` (no inline scripts — put JS in files).
- Serve behind HTTPS in production (note in README; reverse proxy is out of scope).
- `app.set('trust proxy', 1)` only if running behind a proxy.
## Data exposure
- Public endpoints (`/leaderboard`, `/heroes/:name`) return only: name, class, level, kills, gold(optional). Never user ids, usernames, or emails.
- Hero name ≠ login username (so leaderboard doesn't reveal login names).
- Error handler returns generic message for unexpected errors; stack traces go to the server log only.
## SQL
- Prepared statements with `?` / named params only. No string-built SQL, ever. Table/column names are never user-controlled (use allowlist for leaderboard sort `type`).
## Secrets & logging
- `.env` is gitignored. `.env.example` has placeholders only.
- Don't log request bodies on auth routes.
- `audit_log` records login ok/fail, register, logout with IP + timestamp.
## Checklist (Phase 9.1) — tick each in STATUS Session Log
- [ ] No `innerHTML` with dynamic data
- [ ] All routes validated by zod
- [ ] All writes in transactions
- [ ] Ownership checks on every inventory/expedition op
- [ ] Rate limiters mounted
- [ ] Cookie flags verified in browser dev tools
- [ ] Password hash never in any response (grep for `password_hash` in routes)
- [ ] Leaderboard sort allowlisted
- [ ] Production boot fails without SESSION_SECRET
+42
View File
@@ -0,0 +1,42 @@
# TESTING.md
Runner: built-in `node:test` + `node:assert/strict`. No Jest/Mocha.
Script: `"test": "node --test tests/"` (add `--test-concurrency=1` if DB tests collide).
## Layout
- `tests/unit/` — pure functions in `src/game` and `src/utils`. No DB, no HTTP.
- `tests/integration/` — boot `app` with a **temporary SQLite file**, run migrations + seeds, call the real HTTP API.
## Test DB setup helper (`tests/helpers.js`)
- Create temp path via `os.tmpdir()`; set `process.env.DB_PATH` BEFORE requiring `src/db`.
- Run migrate + seed programmatically.
- Start the app on port 0 (`app.listen(0)`), return base URL and a `close()` fn.
- Provide `registerAndLogin(base)` which returns a cookie string to reuse in `fetch` headers.
- Delete the temp file in `after()`.
## What MUST be tested
| Area | Cases |
|---|---|
| energy | no time passed; partial tick; full cap; clock went backwards; large gap |
| leveling | exact XP threshold; multi-level jump; max level cap |
| stats | each class; equipment bonuses applied |
| combat | fixed seed → identical log; hero win; hero loss; round cap; min damage 1 |
| rewards | level-diff multiplier clamps; loot chance 0% / 100% |
| auth | register ok; duplicate (case-insensitive); weak password; login ok/fail; session persists; logout |
| hunt | spends energy; insufficient energy rejected; HP too low rejected; locked zone rejected; rewards persisted |
| inventory | equip swaps slot; can't equip others' items; use potion consumes 1 |
| shop | insufficient gold; level gate; sell equipped rejected; quantities validated |
| expedition | claim before end rejected; claim once only; second start while active rejected |
| leaderboard | sort allowlist; ordering correct |
## Determinism
Inject RNG: `createRng(seed)` (mulberry32) in `src/utils/rng.js` exposing `.float()`, `.int(min,max)`, `.range(min,max)`. In integration tests, allow overriding the RNG via an `app` factory option or env seed.
Inject time: tests set `time.setNow(fn)` to fast-forward energy/expeditions instead of sleeping.
## Naming
`describe('energy.computeEnergy', () => { it('caps at max', ...) })`.
## Rules
- A failing test is never deleted to make the suite green. Fix the code or log a Known Issue.
- New bug → write the failing test first, then fix.
- Update the Test Summary table in STATUS.md after each phase.
+51
View File
@@ -0,0 +1,51 @@
# UI_GUIDE.md
## Pages
| File | Purpose | Auth |
|---|---|---|
| `index.html` | Landing: title, tagline, Login/Register buttons | public |
| `register.html` / `login.html` | forms | public |
| `create-hero.html` | name + class picker with class descriptions | needs login, no hero |
| `game.html` | main dashboard (see layout) | needs login + hero |
| `shop.html` | buy/sell tabs | needs hero |
| `leaderboard.html` | tabs: Level / Gold / Kills | public |
Auth guard on the client: on page load call `/api/auth/me`; redirect as needed. (Server still enforces — client redirect is UX only.)
## game.html layout
```
┌ Header: logo • hero name • Lv • gold • Logout
├ Left column: Hero card (HP bar, XP bar, Energy bar + regen timer), stats, unspent points [+]
├ Centre: Tabs → Hunt | Expedition | Inventory
│ Hunt: zone cards (locked ones greyed with "Reach Lv X")
│ Combat log panel: replays rounds with short delay, then shows summary
└ Right column: Equipment slots (weapon/armor/trinket), recent combat history
```
Mobile (< 700px): single column; tabs become a bottom nav.
## Theme: "Ember & Parchment" (dark fantasy)
CSS variables in `theme.css`:
```
--bg: #14110f; --panel: #211b17; --panel-2: #2c231d;
--text: #eadfce; --muted: #a59a8a;
--accent: #e2822b (ember orange); --accent-2: #f2c14e (gold);
--danger: #c0392b; --success: #4f9d69; --info: #4a7fb0;
--border: #3a2f27;
Rarity: common #b8b0a3, uncommon #5fb36b, rare #4a8fe0, epic #a45ee0, legendary #f2a23a
```
Fonts: system serif stack for headings (`Georgia, 'Times New Roman', serif`), system sans for body. No external font/CDN requests (CSP + offline friendly).
## Components (build once in components.css)
`.panel`, `.btn` (+ `.btn-primary`, `.btn-danger`, `:disabled`), `.bar` with `.bar-fill` (hp/xp/energy variants), `.tabs`, `.card`, `.toast`, `.tooltip`, `.badge` (rarity), `.table`, `.modal`.
## UX rules
- Every action button shows a loading state and is disabled while the request is in flight (prevents double-clicks).
- Show energy cost on the button: `Hunt (5⚡)`. Disable if not enough energy, and say why.
- Errors → toast from `error.message`. Successes → toast or inline result.
- Numbers formatted with thousands separators.
- Countdowns update every second client-side but **re-sync with the server** after each API call.
- Accessible basics: labels on inputs, buttons are `<button>`, colour is not the only signal, focus outlines kept.
- No images required: use emoji/CSS shapes for icons if no art is available (⚔️ 🛡️ 💍 🧪 ⚡ 💰).
## Copy tone
Short, flavourful, never longer than 2 lines. e.g. "A Meadow Wolf lunges from the tall grass!"
+39
View File
@@ -0,0 +1,39 @@
# WORKFLOW.md — How the agent should work
## The loop (repeat for every step)
1. **Orient:** read `STATUS.md` → find the next unchecked step.
2. **Scope:** read only the docs named in that phase heading.
3. **Plan:** write a 3–6 line plan (files to create/edit, what to test).
4. **Build small:** one file or function at a time. Don't write the whole phase in one go.
5. **Verify:** run `npm test`, and start the server and hit the endpoint with `curl` (or a test) before moving on.
6. **Record:** tick the step, update Project Health, add a Session Log line, list new Known Issues.
7. **Re-orient:** go back to 1.
## Definition of Done (per step)
- [ ] Code is in the right folder per ARCHITECTURE.md
- [ ] Inputs validated; errors return the standard error shape (API.md)
- [ ] At least one test covers the happy path and one covers a failure path
- [ ] `npm test` is green
- [ ] No leftover `console.log` debugging; no commented-out code blocks
- [ ] STATUS.md updated
## When something breaks
1. Read the **full** error message and stack trace.
2. Reproduce with the smallest possible command or test.
3. Change **one** thing at a time. Re-run.
4. If stuck after 3 genuine attempts: log it under Known Issues with what you tried, then move to a step that isn't blocked.
## Things that waste time — avoid
- Rewriting working files "to clean them up".
- Adding libraries to dodge writing 10 lines of code.
- Building UI polish before the API works.
- Inventing game mechanics not in GAME_DESIGN.md (add to Stretch Goals instead).
- Editing earlier migrations after they've been run. **Add a new migration.**
## Editing files
- Prefer targeted edits over regenerating whole files.
- After editing, re-read the changed region to confirm it is what you intended.
- Never delete the user's `data/*.db` unless a step says to.
## Commit style (if git is in use)
`phase.step: short description` — e.g. `1.2: add authService register/login`.
+2471
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "emberfall",
"version": "1.0.0",
"description": "A persistent browser-based game (PBBG): players register, create a hero, spend regenerating Energy on actions (hunting, expeditions, training), fight monsters, collect loot, and climb leaderboards. Progress persists between sessions and continues to \"happen\" while the player is offline (timers, energy regen).",
"main": "src/server.js",
"directories": {
"doc": "docs",
"test": "tests"
},
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js",
"test": "NODE_ENV=test node --test --test-concurrency=1 tests/**/*.test.js",
"migrate": "node scripts/migrate.js",
"seed": "node scripts/seed.js"
},
"keywords": [],
"type": "commonjs",
"dependencies": {
"bcryptjs": "^3.0.3",
"better-sqlite3": "^13.0.3",
"better-sqlite3-session-store": "^0.1.0",
"connect-sqlite3": "^0.9.18",
"express": "^5.3.0",
"express-rate-limit": "^8.7.1",
"express-session": "^1.19.0",
"helmet": "^8.3.0",
"zod": "^4.6.7"
}
}
+32
View File
@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Create Hero - Emberfall</title>
<link rel="stylesheet" href="/css/main.css">
</head>
<body>
<div class="auth-container">
<h1>Emberfall</h1>
<h2>Create Your Hero</h2>
<div id="error" class="error" style="display:none;"></div>
<form id="create-hero-form">
<label for="hero-name">Hero Name</label>
<input type="text" id="hero-name" name="hero-name" required minlength="3" maxlength="16" pattern="[A-Za-z0-9 ']+">
<label for="hero-class">Class</label>
<select id="hero-class" name="hero-class" required>
<option value="">Choose a class...</option>
<option value="warrior">Warrior — Armoured frontliner (STR 8, AGI 4, INT 2, VIT 8)</option>
<option value="mage">Mage — Glass-cannon spellcaster (STR 2, AGI 4, INT 9, VIT 5)</option>
<option value="ranger">Ranger — Fast skirmisher (STR 5, AGI 9, INT 3, VIT 5)</option>
</select>
<button type="submit">Begin Your Journey</button>
</form>
</div>
<script src="/js/auth.js"></script>
<script src="/js/create-hero.js"></script>
</body>
</html>
View File
+176
View File
@@ -0,0 +1,176 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background: #1a1a2e;
color: #e0e0e0;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
.auth-container {
background: #16213e;
padding: 2rem 2.5rem;
border-radius: 8px;
width: 100%;
max-width: 360px;
}
h1 { text-align: center; color: #e94560; margin-bottom: 0.25rem; }
h2 { text-align: center; color: #a0a0a0; font-weight: 400; margin-bottom: 1.5rem; }
label {
display: block;
margin-top: 1rem;
margin-bottom: 0.25rem;
font-size: 0.875rem;
color: #a0a0a0;
}
input[type="text"],
input[type="password"] {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid #333;
border-radius: 4px;
background: #0f3460;
color: #e0e0e0;
font-size: 1rem;
}
input:focus { outline: none; border-color: #e94560; }
button {
width: 100%;
margin-top: 1.25rem;
padding: 0.625rem;
background: #e94560;
color: #fff;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
}
button:hover { background: #c73650; }
.error {
background: #5c1111;
color: #ff8888;
padding: 0.5rem 0.75rem;
border-radius: 4px;
margin-bottom: 1rem;
font-size: 0.875rem;
}
.redirect {
text-align: center;
margin-top: 1rem;
font-size: 0.875rem;
}
.redirect a { color: #e94560; }
/* Game dashboard layout */
.game-container {
width: 100%;
max-width: 800px;
margin: 0 auto;
padding: 1rem;
}
.top-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: #16213e;
border-radius: 8px;
margin-bottom: 1rem;
}
.logo { color: #e94560; font-size: 1.25rem; font-weight: bold; }
.user-info { display: flex; align-items: center; gap: 1rem; }
.user-info span { color: #a0a0a0; }
.user-info a { color: #e94560; text-decoration: none; }
.main-content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@media (max-width: 600px) {
.main-content { grid-template-columns: 1fr; }
}
.panel {
background: #16213e;
padding: 1.25rem;
border-radius: 8px;
}
.panel h3 {
color: #e94560;
margin-bottom: 0.75rem;
font-size: 1rem;
}
.stat-row {
display: flex;
justify-content: space-between;
padding: 0.25rem 0;
border-bottom: 1px solid #1a1a2e;
font-size: 0.875rem;
}
.stat-row .label { color: #a0a0a0; }
.stat-row.derived {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid #333;
color: #88cc88;
}
.loading {
text-align: center;
color: #a0a0a0;
padding: 2rem;
}
.placeholder {
color: #666;
font-size: 0.875rem;
padding: 1rem 0;
}
/* Energy bar */
.energy-bar-container {
width: 100%;
height: 12px;
background: #0f3460;
border-radius: 6px;
overflow: hidden;
margin-top: 0.25rem;
}
.energy-bar {
height: 100%;
background: linear-gradient(90deg, #4a90d9, #67b8f7);
border-radius: 6px;
transition: width 0.3s ease;
}
.energy-timer {
font-size: 0.75rem;
color: #666;
margin-top: 0.25rem;
text-align: right;
}
+86
View File
@@ -0,0 +1,86 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Emberfall — Dashboard</title>
<link rel="stylesheet" href="/css/main.css">
</head>
<body>
<div class="game-container">
<!-- Top bar -->
<header class="top-bar">
<div class="logo">Emberfall</div>
<div class="user-info">
<span id="player-name"></span>
<a href="/api/auth/logout" id="logout-btn">Logout</a>
</div>
</header>
<!-- Main content -->
<main class="main-content">
<!-- Stats panel -->
<section class="panel stats-panel" id="stats-panel">
<h3>Your Hero</h3>
<div id="hero-loading" class="loading">Loading...</div>
<div id="hero-error" class="error" style="display:none;"></div>
<div id="hero-stats" style="display:none;">
<div class="stat-row">
<span class="label">Name:</span>
<span id="char-name"></span>
</div>
<div class="stat-row">
<span class="label">Class:</span>
<span id="char-class"></span>
</div>
<div class="stat-row">
<span class="label">Level:</span>
<span id="char-level"></span>
</div>
<div class="stat-row">
<span class="label">HP:</span>
<span id="char-hp"></span>
</div>
<div class="stat-row">
<span class="label">Energy:</span>
<span id="char-energy"></span>
</div>
<div class="energy-bar-container">
<div class="energy-bar" id="energy-bar"></div>
</div>
<div class="energy-timer" id="energy-timer"></div>
<div class="stat-row">
<span class="label">Gold:</span>
<span id="char-gold"></span>
</div>
<div class="stat-row">
<span class="label">XP:</span>
<span id="char-xp"></span>
</div>
<div class="stat-row">
<span class="label">STR:</span><span id="char-str"></span>
<span class="label">AGI:</span><span id="char-agi"></span>
<span class="label">INT:</span><span id="char-int"></span>
<span class="label">VIT:</span><span id="char-vit"></span>
</div>
<div class="stat-row derived">
<span>ATK: <span id="derived-atk"></span></span>
<span>DEF: <span id="derived-def"></span></span>
<span>SPD: <span id="derived-spd"></span></span>
<span>Crit: <span id="derived-crit"></span>%</span>
</div>
</div>
</section>
<!-- Action panel -->
<section class="panel action-panel" id="action-panel">
<h3>Actions</h3>
<p class="placeholder">Hunting and other actions will appear here in the next phase.</p>
</section>
</main>
</div>
<script src="/js/auth.js"></script>
<script src="/js/game.js"></script>
</body>
</html>
View File
View File
+48
View File
@@ -0,0 +1,48 @@
const API_BASE = '/api';
const api = {
async post(path, body) {
const res = await fetch(`${API_BASE}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) {
const msg = data.error ? data.error.message : 'Request failed.';
const errorEl = document.getElementById('error');
if (errorEl) {
errorEl.textContent = msg;
errorEl.style.display = 'block';
}
throw new Error(msg);
}
return data.data;
},
async get(path) {
const res = await fetch(`${API_BASE}${path}`, {
credentials: 'include',
});
const data = await res.json();
if (!res.ok) {
const msg = data.error ? data.error.message : 'Request failed.';
const errorEl = document.getElementById('error');
if (errorEl) {
errorEl.textContent = msg;
errorEl.style.display = 'block';
}
throw new Error(msg);
}
return data.data;
},
};
function showError(msg) {
const errorEl = document.getElementById('error');
if (errorEl) {
errorEl.textContent = msg;
errorEl.style.display = 'block';
}
}
+22
View File
@@ -0,0 +1,22 @@
document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('create-hero-form');
if (!form) return;
form.addEventListener('submit', async (e) => {
e.preventDefault();
const name = document.getElementById('hero-name').value.trim();
const classKey = document.getElementById('hero-class').value;
if (!name || !classKey) {
showError('Please enter a hero name and choose a class.');
return;
}
try {
await api.post('/character', { name, class: classKey });
window.location.href = '/game.html';
} catch (_) {
// Error already shown via api()
}
});
});
+101
View File
@@ -0,0 +1,101 @@
// Game dashboard: load character profile and populate stats.
let energyInterval = null;
async function loadCharacter() {
try {
const data = await api.get('/character');
if (!data) {
// No character yet — redirect to create-hero
window.location.href = '/create-hero.html';
return;
}
const { character, derived, energy } = data;
// Show stats, hide loading
document.getElementById('hero-loading').style.display = 'none';
document.getElementById('hero-stats').style.display = 'block';
// Basic info
document.getElementById('player-name').textContent = character.name;
document.getElementById('char-name').textContent = character.name;
document.getElementById('char-class').textContent = character.class.charAt(0).toUpperCase() + character.class.slice(1);
document.getElementById('char-level').textContent = character.level;
document.getElementById('char-hp').textContent = `${character.hp_current} / ${derived.maxHp}`;
document.getElementById('char-energy').textContent = `${energy.current} / ${energy.max}`;
document.getElementById('char-gold').textContent = character.gold;
document.getElementById('char-xp').textContent = `${character.xp} / ${xpToNext(character.level)}`;
document.getElementById('char-str').textContent = character.str;
document.getElementById('char-agi').textContent = character.agi;
document.getElementById('char-int').textContent = character.int;
document.getElementById('char-vit').textContent = character.vit;
document.getElementById('derived-atk').textContent = derived.attack;
document.getElementById('derived-def').textContent = derived.defense;
document.getElementById('derived-spd').textContent = derived.speed;
document.getElementById('derived-crit').textContent = derived.critChance;
// Update energy bar
updateEnergyBar(energy.current, energy.max, energy.nextTickInSec);
} catch (err) {
// Not authenticated — redirect to login
window.location.href = '/login.html';
}
}
function xpToNext(level) {
return Math.floor(100 * Math.pow(level, 1.5));
}
function updateEnergyBar(current, max, nextTickSec) {
// Update text
const energyEl = document.getElementById('char-energy');
if (energyEl) energyEl.textContent = `${current} / ${max}`;
// Update bar fill
const barEl = document.getElementById('energy-bar');
if (barEl) {
const pct = Math.round((current / max) * 100);
barEl.style.width = `${pct}%`;
barEl.title = `${current}/${max} energy`;
}
// Update countdown
const timerEl = document.getElementById('energy-timer');
if (timerEl) {
if (current >= max) {
timerEl.textContent = 'Energy full';
} else {
const mins = Math.floor(nextTickSec / 60);
const secs = nextTickSec % 60;
timerEl.textContent = `Next +1 in ${mins}:${secs.toString().padStart(2, '0')}`;
}
}
// Start countdown
if (energyInterval) clearInterval(energyInterval);
if (current < max && nextTickSec > 0) {
energyInterval = setInterval(() => {
nextTickSec--;
if (nextTickSec <= 0) {
clearInterval(energyInterval);
loadCharacter();
return;
}
const mins = Math.floor(nextTickSec / 60);
const secs = nextTickSec % 60;
if (timerEl) timerEl.textContent = `Next +1 in ${mins}:${secs.toString().padStart(2, '0')}`;
}, 1000);
}
}
// Logout handler
document.getElementById('logout-btn')?.addEventListener('click', async (e) => {
e.preventDefault();
try {
await api.post('/auth/logout', {});
} catch (_) {}
window.location.href = '/login.html';
});
document.addEventListener('DOMContentLoaded', loadCharacter);
+21
View File
@@ -0,0 +1,21 @@
document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('login-form');
if (!form) return;
form.addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
try {
const data = await api.post('/auth/login', { username, password });
if (data.hasHero) {
window.location.href = '/game.html';
} else {
window.location.href = '/create-hero.html';
}
} catch (_) {
// Error already shown via api()
}
});
});
+17
View File
@@ -0,0 +1,17 @@
document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('register-form');
if (!form) return;
form.addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
try {
await api.post('/auth/register', { username, password });
window.location.href = '/create-hero.html';
} catch (_) {
// Error already shown via api()
}
});
});
+26
View File
@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - Emberfall</title>
<link rel="stylesheet" href="/css/main.css">
</head>
<body>
<div class="auth-container">
<h1>Emberfall</h1>
<h2>Login</h2>
<div id="error" class="error" style="display:none;"></div>
<form id="login-form">
<label for="username">Username</label>
<input type="text" id="username" name="username" required minlength="3" maxlength="20" autocomplete="username">
<label for="password">Password</label>
<input type="password" id="password" name="password" required minlength="8" maxlength="72" autocomplete="current-password">
<button type="submit">Login</button>
</form>
<p class="redirect">Don't have an account? <a href="/register.html">Register</a></p>
</div>
<script src="/js/auth.js"></script>
<script src="/js/login.js"></script>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Register - Emberfall</title>
<link rel="stylesheet" href="/css/main.css">
</head>
<body>
<div class="auth-container">
<h1>Emberfall</h1>
<h2>Register</h2>
<div id="error" class="error" style="display:none;"></div>
<form id="register-form">
<label for="username">Username</label>
<input type="text" id="username" name="username" required minlength="3" maxlength="20" pattern="[A-Za-z0-9_]+" autocomplete="username">
<label for="password">Password</label>
<input type="password" id="password" name="password" required minlength="8" maxlength="72" autocomplete="new-password">
<button type="submit">Register</button>
</form>
<p class="redirect">Already have an account? <a href="/login.html">Login</a></p>
</div>
<script src="/js/auth.js"></script>
<script src="/js/register.js"></script>
</body>
</html>
View File
+54
View File
@@ -0,0 +1,54 @@
const path = require('path');
const fs = require('fs');
const { getDb } = require('../src/db');
const migrationsDir = path.resolve(__dirname, '..', 'src', 'db', 'migrations');
function runMigrations() {
const db = getDb();
// Create _migrations table if it doesn't exist
db.exec(`
CREATE TABLE IF NOT EXISTS _migrations (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
// Get already-applied migrations
const applied = new Set(
db.prepare('SELECT name FROM _migrations').pluck().all()
);
// Find migration files
const files = fs.readdirSync(migrationsDir)
.filter(f => f.endsWith('.sql'))
.sort();
for (const file of files) {
if (applied.has(file)) {
console.log(` SKIP ${file} (already applied)`);
continue;
}
const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf-8');
console.log(` APPLY ${file}`);
// Wrap in a transaction
db.transaction(() => {
db.exec(sql);
db.prepare('INSERT INTO _migrations (name) VALUES (?)').run(file);
})();
}
console.log('Migrations complete.');
}
// Run if called directly
if (require.main === module) {
runMigrations();
process.exit(0);
}
module.exports = { runMigrations };
+81
View File
@@ -0,0 +1,81 @@
const express = require('express');
const helmet = require('helmet');
const { createSessionMiddleware, requireAuth } = require('./middleware/session');
const { registerLimiter, loginLimiter, routes: authRoutes } = require('./routes/auth');
const { routes: characterRoutes } = require('./routes/character');
const { AppError } = require('./utils/errors');
function createApp() {
const app = express();
// Security headers
app.use(helmet());
// Trust proxy (for rate limiting behind reverse proxy)
app.set('trust proxy', 1);
// JSON body parser with small limit
app.use(express.json({ limit: '10kb' }));
// Session middleware
app.use(createSessionMiddleware());
// Static files
app.use(express.static('public'));
// Health endpoint (no auth)
app.get('/api/health', (_req, res) => {
res.json({ ok: true });
});
// Auth routes (public)
app.post('/api/auth/register', registerLimiter, authRoutes.postRegister);
app.post('/api/auth/login', loginLimiter, authRoutes.postLogin);
// Auth routes (require session)
app.post('/api/auth/logout', requireAuth, authRoutes.postLogout);
app.get('/api/auth/me', requireAuth, authRoutes.getMe);
// Character routes (require session)
app.post('/api/character', requireAuth, characterRoutes.postCharacter);
app.get('/api/character', requireAuth, characterRoutes.getCharacter);
// Central error handler
app.use((err, _req, res, _next) => {
// Log stack traces server-side only
if (err.stack) {
console.error(err.stack);
}
if (err instanceof AppError) {
return res.status(err.status).json({
error: {
code: err.code,
message: err.message,
},
});
}
// Check for Zod-like validation errors
if (err.name === 'ZodError' || (err.errors && Array.isArray(err.errors))) {
return res.status(400).json({
error: {
code: 'VALIDATION_ERROR',
message: 'Request validation failed.',
},
});
}
// Generic error
res.status(500).json({
error: {
code: 'INTERNAL_ERROR',
message: 'Something went wrong.',
},
});
});
return app;
}
module.exports = { createApp };
View File
+27
View File
@@ -0,0 +1,27 @@
const path = require('path');
// Load .env from project root
const projectRoot = path.resolve(__dirname, '..', '..');
process.loadEnvFile(path.join(projectRoot, '.env'));
const requiredVars = ['PORT', 'NODE_ENV', 'SESSION_SECRET', 'DB_PATH'];
for (const varName of requiredVars) {
if (!process.env[varName]) {
throw new Error(`Missing required env var: ${varName}`);
}
}
// In production, SESSION_SECRET must not be a placeholder
if (process.env.NODE_ENV === 'production' && process.env.SESSION_SECRET === 'change-me-to-a-long-random-string') {
throw new Error('SESSION_SECRET in production must not be the default placeholder');
}
const config = Object.freeze({
port: parseInt(process.env.PORT, 10),
nodeEnv: process.env.NODE_ENV,
sessionSecret: process.env.SESSION_SECRET,
dbPath: path.resolve(projectRoot, process.env.DB_PATH),
});
module.exports = config;
+28
View File
@@ -0,0 +1,28 @@
const path = require('path');
const Database = require('better-sqlite3');
const config = require('../config');
let db;
function getDb() {
if (!db) {
const dbDir = path.dirname(config.dbPath);
const fs = require('fs');
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
db = new Database(config.dbPath, {
wal: true,
nativeTimeout: 'session',
});
// Configure journal mode and enable foreign keys
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
}
return db;
}
module.exports = { getDb };
View File
+10
View File
@@ -0,0 +1,10 @@
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
password_hash TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_login_at INTEGER,
is_banned INTEGER NOT NULL DEFAULT 0 CHECK (is_banned IN (0,1))
);
CREATE UNIQUE INDEX idx_users_username_nocase ON users(username COLLATE NOCASE);
+25
View File
@@ -0,0 +1,25 @@
CREATE TABLE characters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
class TEXT NOT NULL CHECK (class IN ('warrior','mage','ranger')),
level INTEGER NOT NULL DEFAULT 1 CHECK (level >= 1),
xp INTEGER NOT NULL DEFAULT 0 CHECK (xp >= 0),
gold INTEGER NOT NULL DEFAULT 50 CHECK (gold >= 0),
str INTEGER NOT NULL,
agi INTEGER NOT NULL,
int INTEGER NOT NULL,
vit INTEGER NOT NULL,
unspent_points INTEGER NOT NULL DEFAULT 0 CHECK (unspent_points >= 0),
hp_current INTEGER NOT NULL CHECK (hp_current >= 0),
energy INTEGER NOT NULL CHECK (energy >= 0),
energy_updated_at INTEGER NOT NULL,
kills INTEGER NOT NULL DEFAULT 0,
deaths INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX idx_characters_name_nocase ON characters(name COLLATE NOCASE);
CREATE INDEX idx_characters_level ON characters(level DESC, xp DESC);
CREATE INDEX idx_characters_gold ON characters(gold DESC);
CREATE INDEX idx_characters_kills ON characters(kills DESC);
View File
View File
+44
View File
@@ -0,0 +1,44 @@
// Class definitions: base stats and fantasy role for each class.
const CLASSES = Object.freeze({
warrior: {
name: 'Warrior',
fantasy: 'Armoured frontliner',
base: { str: 8, agi: 4, int: 2, vit: 8 },
// Primary stat for attack calculation
primary: 'str',
// Class-specific bonuses (applied to derived stats)
bonuses: { defensePct: 0.10 },
},
mage: {
name: 'Mage',
fantasy: 'Glass-cannon spellcaster',
base: { str: 2, agi: 4, int: 9, vit: 5 },
primary: 'int',
bonuses: { magicVsHighDef: 0.15 },
},
ranger: {
name: 'Ranger',
fantasy: 'Fast skirmisher',
base: { str: 5, agi: 9, int: 3, vit: 5 },
primary: 'agi',
bonuses: { critChance: 0.08 },
},
});
const CLASS_KEYS = Object.freeze(Object.keys(CLASSES));
function getClass(classKey) {
const cls = CLASSES[classKey];
if (!cls) {
throw new Error(`Unknown class: ${classKey}. Valid: ${CLASS_KEYS.join(', ')}`);
}
return cls;
}
function getBaseStats(classKey) {
const cls = getClass(classKey);
return { ...cls.base };
}
module.exports = { CLASSES, CLASS_KEYS, getClass, getBaseStats };
+47
View File
@@ -0,0 +1,47 @@
// Energy regeneration: pure function based on BALANCE.md formulas.
const ENERGY_MAX = 100;
const ENERGY_REGEN_SECONDS = 180; // +1 per 3 minutes
/**
* Compute current energy based on time elapsed since last update.
* Uses lazy evaluation: no timers, just math on timestamps.
*
* @param {number} stored - stored energy value (0-100)
* @param {number} updatedAtMs - last energy update timestamp (ms epoch)
* @param {number} nowMs - current time (ms epoch)
* @returns {{ energy: number, updatedAtMs: number }}
*/
function computeEnergy(stored, updatedAtMs, nowMs) {
// Clock went backwards or same time — return as-is
if (nowMs <= updatedAtMs) {
return { energy: stored, updatedAtMs };
}
const elapsedMs = nowMs - updatedAtMs;
const gained = Math.floor(elapsedMs / (ENERGY_REGEN_SECONDS * 1000));
// Less than one full regen tick — no change
if (gained <= 0) {
return { energy: stored, updatedAtMs };
}
const newEnergy = stored + gained;
// Cap at max and reset timestamp
if (newEnergy >= ENERGY_MAX) {
return { energy: ENERGY_MAX, updatedAtMs: nowMs };
}
// Advance timestamp by whole ticks only (keep partial)
return {
energy: newEnergy,
updatedAtMs: updatedAtMs + gained * ENERGY_REGEN_SECONDS * 1000,
};
}
module.exports = {
ENERGY_MAX,
ENERGY_REGEN_SECONDS,
computeEnergy,
};
+111
View File
@@ -0,0 +1,111 @@
// Derived stats calculation based on BALANCE.md formulas.
// All functions are pure — take data in, return data out.
const { getClass } = require('./classes');
const { computeEnergy: _computeEnergy, ENERGY_MAX, ENERGY_REGEN_SECONDS } = require('./energy');
/**
* Calculate derived stats from base stats, level, and equipment bonuses.
* @param {object} stats - { str, agi, int, vit }
* @param {number} level - hero level
* @param {string} classKey - 'warrior' | 'mage' | 'ranger'
* @param {object} equipBonuses - { bonus_str, bonus_agi, bonus_int, bonus_vit, bonus_attack, bonus_defense }
* @returns {object} derived stats
*/
function calcDerived(stats, level, classKey, equipBonuses = {}) {
const cls = getClass(classKey);
// Total stats = base + equipment bonuses
const totalStr = stats.str + (equipBonuses.bonus_str || 0);
const totalAgi = stats.agi + (equipBonuses.bonus_agi || 0);
const totalInt = stats.int + (equipBonuses.bonus_int || 0);
const totalVit = stats.vit + (equipBonuses.bonus_vit || 0);
// Primary stat for attack
const primary = cls.primary === 'str' ? totalStr
: cls.primary === 'agi' ? totalAgi
: totalInt;
// maxHp = 50 + vit*10 + level*5 + itemBonusVit*10
const maxHp = 50 + totalVit * 10 + level * 5 + (equipBonuses.bonus_vit || 0) * 10;
// attack = 5 + primary*2 + level + equipment.bonus_attack
const attack = 5 + primary * 2 + level + (equipBonuses.bonus_attack || 0);
// defense = floor(vit*1.5) + equipment.bonus_defense (+10% if Warrior)
let defense = Math.floor(totalVit * 1.5) + (equipBonuses.bonus_defense || 0);
if (cls.bonuses.defensePct) {
defense = Math.floor(defense * (1 + cls.bonuses.defensePct));
}
// speed = 10 + agi
const speed = 10 + totalAgi;
// critChance% = min(40, 5 + floor(agi*0.5)) (+8 if Ranger)
let critChance = Math.min(40, 5 + Math.floor(totalAgi * 0.5));
if (cls.bonuses.critChance) {
critChance = Math.min(40, critChance + Math.floor(cls.bonuses.critChance * 100));
}
return {
maxHp,
attack,
defense,
speed,
critChance,
critMult: 1.5,
primary,
};
}
/**
* Energy regeneration: re-exported from energy.js for backward compat.
*/
const computeEnergy = _computeEnergy;
/**
* XP required to reach next level from given level.
* @param {number} level - current level
* @returns {number} XP needed
*/
function xpToNext(level) {
return Math.floor(100 * Math.pow(level, 1.5));
}
/**
* Check if hero leveled up and return new level + unspent points.
* @param {number} currentLevel - current level
* @param {number} currentXp - current XP
* @param {number} xpGained - XP gained from this action
* @returns {object} { newLevel, leveledUp, totalXp, unspentPoints }
*/
function checkLevelUp(currentLevel, currentXp, xpGained) {
let totalXp = currentXp + xpGained;
let level = currentLevel;
let xpNeeded = xpToNext(level);
while (totalXp >= xpNeeded && level < 50) {
totalXp -= xpNeeded;
level += 1;
xpNeeded = xpToNext(level);
}
const leveledUp = level > currentLevel;
const unspentPoints = (level - currentLevel) * 3;
return {
newLevel: level,
leveledUp,
totalXp,
unspentPoints,
};
}
module.exports = {
ENERGY_MAX,
ENERGY_REGEN_SECONDS,
calcDerived,
computeEnergy,
xpToNext,
checkLevelUp,
};
View File
+50
View File
@@ -0,0 +1,50 @@
const session = require('express-session');
const SessionStore = require('better-sqlite3-session-store')(session);
const config = require('../config');
const { getDb } = require('../db');
const { AppError } = require('../utils/errors');
function createSessionMiddleware() {
const db = getDb();
return session({
store: new SessionStore({
client: db,
table: 'sessions',
expired: {
clear: true,
intervalMs: 900000, // 15 min cleanup
},
}),
secret: config.sessionSecret,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
secure: config.nodeEnv === 'production',
},
name: 'emberfall_session',
rolling: true,
});
}
function requireAuth(req, res, next) {
if (!req.session || !req.session.userId) {
// Return JSON error for API requests
if (req.path.startsWith('/api/') || req.xhr) {
return res.status(401).json({
error: {
code: 'UNAUTHENTICATED',
message: 'Authentication required.',
},
});
}
// Redirect to login for page requests
return res.redirect('/login.html');
}
next();
}
module.exports = { createSessionMiddleware, requireAuth };
View File
+90
View File
@@ -0,0 +1,90 @@
const rateLimit = require('express-rate-limit');
const zod = require('zod');
const { AppError } = require('../utils/errors');
const { register, verifyLogin } = require('../services/authService');
// Validation schemas
const registerSchema = zod.strictObject({
username: zod.string().min(3).max(20).regex(/^[A-Za-z0-9_]+$/),
password: zod.string().min(8).max(72),
});
const loginSchema = zod.strictObject({
username: zod.string().min(1).max(20),
password: zod.string().min(1).max(72),
});
// Rate limiters
const registerLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: process.env.NODE_ENV === 'test' ? 1000 : 5,
message: { error: { code: 'RATE_LIMITED', message: 'Too many registration attempts. Try again later.' } },
});
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: process.env.NODE_ENV === 'test' ? 1000 : 10,
message: { error: { code: 'RATE_LIMITED', message: 'Too many login attempts. Try again later.' } },
});
function wrap(fn) {
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
}
async function handleRegister(req, res) {
const parsed = registerSchema.safeParse(req.body);
if (!parsed.success) {
throw new AppError('VALIDATION_ERROR', 'Request validation failed.', 400);
}
const { username, password } = parsed.data;
const user = await register(username, password);
// Regenerate session to prevent fixation
req.session.regenerate((err) => {
if (err) throw new AppError('INTERNAL_ERROR', 'Session regeneration failed.', 500);
req.session.userId = user.id;
res.status(201).json({ ok: true, data: { user: { id: user.id, username: user.username } } });
});
}
async function handleLogin(req, res) {
const parsed = loginSchema.safeParse(req.body);
if (!parsed.success) {
throw new AppError('VALIDATION_ERROR', 'Request validation failed.', 400);
}
const { username, password } = parsed.data;
const user = await verifyLogin(username, password);
// Regenerate session to prevent fixation
req.session.regenerate((err) => {
if (err) throw new AppError('INTERNAL_ERROR', 'Session regeneration failed.', 500);
req.session.userId = user.id;
res.json({ ok: true, data: { user: { id: user.id, username: user.username }, hasHero: false } });
});
}
async function handleLogout(req, res) {
req.session.destroy((err) => {
if (err) throw new AppError('INTERNAL_ERROR', 'Logout failed.', 500);
res.clearCookie('emberfall_session');
res.json({ ok: true, data: {} });
});
}
async function handleMe(req, res) {
const userId = req.session.userId;
res.json({ ok: true, data: { user: { id: userId, username: req.session.username || '' }, hasHero: false } });
}
module.exports = {
registerLimiter,
loginLimiter,
routes: {
postRegister: wrap(handleRegister),
postLogin: wrap(handleLogin),
postLogout: wrap(handleLogout),
getMe: wrap(handleMe),
},
};
+51
View File
@@ -0,0 +1,51 @@
// Character routes: POST /api/character (create), GET /api/character (profile).
const zod = require('zod');
const { AppError } = require('../utils/errors');
const { requireAuth } = require('../middleware/session');
const { createCharacter, getCharacterProfile } = require('../services/characterService');
const { CLASS_KEYS } = require('../game/classes');
// Validation schemas
const createSchema = zod.strictObject({
name: zod.string().min(3).max(16).regex(/^[A-Za-z0-9 ']+$/).trim(),
class: zod.enum(CLASS_KEYS),
});
function wrap(fn) {
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
}
async function handleCreate(req, res) {
const parsed = createSchema.safeParse(req.body);
if (!parsed.success) {
throw new AppError('VALIDATION_ERROR', 'Request validation failed.', 400);
}
const { name, class: classKey } = parsed.data;
const userId = req.session.userId;
const result = createCharacter(userId, name, classKey);
res.status(201).json({
ok: true,
data: { character: { id: result.id, name, class: classKey } },
});
}
async function handleGet(req, res) {
const userId = req.session.userId;
const profile = getCharacterProfile(userId);
if (!profile) {
return res.json({ ok: true, data: null });
}
res.json({ ok: true, data: profile });
}
module.exports = {
routes: {
postCharacter: wrap(handleCreate),
getCharacter: wrap(handleGet),
},
};
+10
View File
@@ -0,0 +1,10 @@
const config = require('./config');
const { createApp } = require('./app');
const app = createApp();
const server = app.listen(config.port, () => {
console.log(`Emberfall server running on http://localhost:${config.port}`);
});
module.exports = { app, server };
View File
+77
View File
@@ -0,0 +1,77 @@
const bcrypt = require('bcryptjs');
const { getDb } = require('../db');
const { AppError } = require('../utils/errors');
const BCRYPT_COST = 12;
// Prepared statements — created once at module load
const stmts = {
findByUsername: (db) => db.prepare(
'SELECT id, username, password_hash, is_banned FROM users WHERE username = ? COLLATE NOCASE'
),
insertUser: (db) => db.prepare(
'INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, ?)'
),
};
let cachedStmts;
function getStmts() {
if (!cachedStmts) {
const db = getDb();
cachedStmts = {
findByUsername: stmts.findByUsername(db),
insertUser: stmts.insertUser(db),
};
}
return cachedStmts;
}
async function register(username, password) {
const db = getDb();
const s = getStmts();
// Check if username already exists (case-insensitive)
const existing = s.findByUsername.get(username);
if (existing) {
throw new AppError('USERNAME_TAKEN', 'Username is already taken.', 409);
}
const passwordHash = await bcrypt.hash(password, BCRYPT_COST);
const createdAt = Date.now();
const result = s.insertUser.run(username, passwordHash, createdAt);
const user = { id: result.lastInsertRowid, username };
return user;
}
async function verifyLogin(username, password) {
const db = getDb();
const s = getStmts();
// Look up user (case-insensitive)
const user = s.findByUsername.get(username);
if (!user) {
// Dummy hash to prevent timing attacks
await bcrypt.hash(password, BCRYPT_COST);
throw new AppError('INVALID_CREDENTIALS', 'Invalid username or password.', 401);
}
if (user.is_banned) {
throw new AppError('BANNED', 'Your account has been banned.', 403);
}
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) {
throw new AppError('INVALID_CREDENTIALS', 'Invalid username or password.', 401);
}
// Update last_login_at
db.prepare('UPDATE users SET last_login_at = ? WHERE id = ?').run(Date.now(), user.id);
const { password_hash: _, ...safeUser } = user;
return safeUser;
}
module.exports = { register, verifyLogin };
+180
View File
@@ -0,0 +1,180 @@
// Character service: create and retrieve characters with derived stats and energy.
const { getDb } = require('../db');
const { AppError } = require('../utils/errors');
const { getBaseStats } = require('../game/classes');
const { calcDerived, computeEnergy, ENERGY_MAX } = require('../game/stats');
const { computeEnergy: computeEnergyPure } = require('../game/energy');
const ENERGY_UPDATED_AT_DEFAULT = Date.now();
function wrapStmts(db) {
return {
findByUserId: db.prepare(
'SELECT * FROM characters WHERE user_id = ?'
),
findByName: db.prepare(
'SELECT id FROM characters WHERE name = ? COLLATE NOCASE'
),
insertCharacter: db.prepare(
`INSERT INTO characters
(user_id, name, class, level, xp, gold, str, agi, int, vit,
unspent_points, hp_current, energy, energy_updated_at, kills, deaths, created_at)
VALUES (?, ?, ?, 1, 0, 50, ?, ?, ?, ?, 0, ?, ?, ?, 0, 0, ?)`
),
};
}
let cachedStmts;
function getStmts() {
if (!cachedStmts) {
const db = getDb();
cachedStmts = wrapStmts(db);
}
return cachedStmts;
}
/**
* Create a new character for the current user.
*/
function createCharacter(userId, name, classKey) {
const db = getDb();
const stmts = getStmts();
const baseStats = getBaseStats(classKey);
const now = Date.now();
// Check if user already has a character
const existing = stmts.findByUserId.get(userId);
if (existing) {
throw new AppError('HERO_EXISTS', 'You already have a character.', 409);
}
// Check if name is taken
const nameCheck = stmts.findByName.get(name);
if (nameCheck) {
throw new AppError('NAME_TAKEN', 'Hero name is already taken.', 409);
}
const hpCurrent = 50 + baseStats.vit * 10 + 5; // level 1: 50 + vit*10 + level*5
const result = stmts.insertCharacter.run(
userId, name, classKey,
baseStats.str, baseStats.agi, baseStats.int, baseStats.vit,
hpCurrent, ENERGY_MAX, ENERGY_UPDATED_AT_DEFAULT, now
);
return { id: result.lastInsertRowid };
}
/**
* Get character profile with derived stats and current energy.
*/
function getCharacterProfile(userId) {
const stmts = getStmts();
const character = stmts.findByUserId.get(userId);
if (!character) {
return null;
}
// Compute derived stats (with no equipment bonuses for now)
const derived = calcDerived(
{ str: character.str, agi: character.agi, int: character.int, vit: character.vit },
character.level,
character.class
);
// Compute current energy with lazy regen
const energy = computeEnergy(character.energy, character.energy_updated_at, Date.now());
// Update energy in DB if it changed
if (energy.energy !== character.energy || energy.updatedAtMs !== character.energy_updated_at) {
const updateEnergy = getDb().prepare(
'UPDATE characters SET energy = ?, energy_updated_at = ? WHERE id = ?'
);
updateEnergy.run(energy.energy, energy.updatedAtMs, character.id);
// Refresh character data
Object.assign(character, { energy: energy.energy, energy_updated_at: energy.updatedAtMs });
}
// Calculate next energy tick
const now = Date.now();
const nextTickMs = character.energy_updated_at + (ENERGY_MAX - character.energy) * 180 * 1000;
const nextTickSec = Math.max(0, Math.ceil((nextTickMs - now) / 1000));
return {
character: {
id: character.id,
name: character.name,
class: character.class,
level: character.level,
xp: character.xp,
gold: character.gold,
str: character.str,
agi: character.agi,
int: character.int,
vit: character.vit,
unspent_points: character.unspent_points,
hp_current: character.hp_current,
maxHp: derived.maxHp,
kills: character.kills,
deaths: character.deaths,
},
derived: {
maxHp: derived.maxHp,
attack: derived.attack,
defense: derived.defense,
speed: derived.speed,
critChance: derived.critChance,
critMult: derived.critMult,
},
energy: {
current: character.energy,
max: ENERGY_MAX,
nextTickInSec: nextTickSec,
},
};
}
/**
* Spend energy from a character. Runs in a transaction.
* First applies lazy energy regen, then deducts the cost.
* @param {number} characterId - the character's ID
* @param {number} cost - energy to spend
* @returns {object} { energy, updatedAtMs } new energy state
*/
function spendEnergy(characterId, cost) {
const db = getDb();
const now = Date.now();
// Validate cost
if (typeof cost !== 'number' || cost <= 0 || !Number.isInteger(cost)) {
throw new AppError('VALIDATION_ERROR', 'Cost must be a positive integer.', 400);
}
// Get character
const character = db.prepare('SELECT * FROM characters WHERE id = ?').get(characterId);
if (!character) {
throw new AppError('NOT_FOUND', 'Character not found.', 404);
}
// Compute current energy with lazy regen
const energy = computeEnergyPure(character.energy, character.energy_updated_at, now);
// Check if enough energy
if (energy.energy < cost) {
throw new AppError('INSUFFICIENT_ENERGY', `Not enough energy. Have ${energy.energy}, need ${cost}.`, 422);
}
// Deduct energy in a transaction
const updateEnergy = db.transaction((newEnergy, newUpdatedAt, cid) => {
db.prepare('UPDATE characters SET energy = ?, energy_updated_at = ? WHERE id = ?').run(newEnergy, newUpdatedAt, cid);
});
updateEnergy(energy.energy - cost, energy.updatedAtMs, characterId);
return { energy: energy.energy - cost, updatedAtMs: energy.updatedAtMs };
}
module.exports = { createCharacter, getCharacterProfile, spendEnergy };
View File
+9
View File
@@ -0,0 +1,9 @@
class AppError extends Error {
constructor(code, message, status = 400) {
super(message);
this.code = code;
this.status = status;
}
}
module.exports = { AppError };
+171
View File
@@ -0,0 +1,171 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const http = require('node:http');
const { setup, teardown, cleanup } = require('./helpers');
// Simple cookie jar for tests
function createJar() {
return { cookies: [] };
}
function jarSet(jar, res) {
const cookies = res.headers['set-cookie'];
if (cookies && cookies.length) {
jar.cookies = cookies.map((c) => c.split(';')[0]);
}
}
function jarGet(jar) {
return jar.cookies.join('; ');
}
function fetchJson(base, path, options = {}) {
return new Promise((resolve, reject) => {
const url = new URL(path, base);
const opts = {
method: options.method || 'GET',
headers: {
'Content-Type': 'application/json',
...(options.cookie ? { Cookie: options.cookie } : {}),
},
};
const req = http.request(url, opts, (res) => {
let data = '';
res.on('data', (c) => { data += c; });
res.on('end', () => {
resolve({
status: res.statusCode,
headers: res.headers,
body: data ? JSON.parse(data) : null,
});
});
});
req.on('error', reject);
if (options.body) req.write(JSON.stringify(options.body));
req.end();
});
}
test('auth register ok', async () => {
const baseUrl = await setup();
try {
const res = await fetchJson(baseUrl, '/api/auth/register', {
method: 'POST',
body: { username: 'testuser', password: 'password123' },
});
assert.equal(res.status, 201);
assert.equal(res.body.ok, true);
assert.equal(res.body.data.user.username, 'testuser');
} finally {
await teardown();
cleanup();
}
});
test('auth duplicate username', async () => {
const baseUrl = await setup();
try {
await fetchJson(baseUrl, '/api/auth/register', {
method: 'POST',
body: { username: 'dupuser', password: 'password123' },
});
const res = await fetchJson(baseUrl, '/api/auth/register', {
method: 'POST',
body: { username: 'DupUser', password: 'password123' },
});
assert.equal(res.status, 409);
assert.equal(res.body.error.code, 'USERNAME_TAKEN');
} finally {
await teardown();
cleanup();
}
});
test('auth login ok', async () => {
const baseUrl = await setup();
try {
await fetchJson(baseUrl, '/api/auth/register', {
method: 'POST',
body: { username: 'logintest', password: 'password123' },
});
const res = await fetchJson(baseUrl, '/api/auth/login', {
method: 'POST',
body: { username: 'logintest', password: 'password123' },
});
assert.equal(res.status, 200);
assert.equal(res.body.ok, true);
assert.equal(res.body.data.user.username, 'logintest');
assert.equal(res.body.data.hasHero, false);
} finally {
await teardown();
cleanup();
}
});
test('auth login bad password', async () => {
const baseUrl = await setup();
try {
await fetchJson(baseUrl, '/api/auth/register', {
method: 'POST',
body: { username: 'badpass', password: 'password123' },
});
const res = await fetchJson(baseUrl, '/api/auth/login', {
method: 'POST',
body: { username: 'badpass', password: 'wrongpassword' },
});
assert.equal(res.status, 401);
assert.equal(res.body.error.code, 'INVALID_CREDENTIALS');
} finally {
await teardown();
cleanup();
}
});
test('auth me requires session', async () => {
const baseUrl = await setup();
try {
const res = await fetchJson(baseUrl, '/api/auth/me');
assert.equal(res.status, 401);
assert.equal(res.body.error.code, 'UNAUTHENTICATED');
} finally {
await teardown();
cleanup();
}
});
test('auth logout clears session', async () => {
const baseUrl = await setup();
const jar = createJar();
try {
// Register
const regRes = await fetchJson(baseUrl, '/api/auth/register', {
method: 'POST',
body: { username: 'logouttest', password: 'password123' },
});
assert.equal(regRes.status, 201);
jarSet(jar, regRes);
// Me should work with session from register
const meRes1 = await fetchJson(baseUrl, '/api/auth/me', {
cookie: jarGet(jar),
});
assert.equal(meRes1.status, 200);
// Logout
const logoutRes = await fetchJson(baseUrl, '/api/auth/logout', {
method: 'POST',
cookie: jarGet(jar),
});
assert.equal(logoutRes.status, 200);
// Me should fail after logout
const meRes2 = await fetchJson(baseUrl, '/api/auth/me', {
cookie: jarGet(jar),
});
assert.equal(meRes2.status, 401);
assert.equal(meRes2.body.error.code, 'UNAUTHENTICATED');
} finally {
await teardown();
cleanup();
}
});
+36
View File
@@ -0,0 +1,36 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const http = require('node:http');
const { createApp } = require('../src/app');
function startServer(app) {
return new Promise((resolve) => {
const server = app.listen(0, () => {
const port = server.address().port;
resolve({ server, port });
});
});
}
test('GET /api/health returns { ok: true }', async () => {
const app = createApp();
const { server, port } = await startServer(app);
try {
const result = await new Promise((resolve, reject) => {
http.get(`http://localhost:${port}/api/health`, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
resolve({ status: res.statusCode, body: JSON.parse(data) });
});
res.on('error', reject);
});
});
assert.equal(result.status, 200);
assert.equal(result.body.ok, true);
} finally {
server.close();
}
});
+69
View File
@@ -0,0 +1,69 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
let tmpDir;
let dbPath;
let server;
function createFreshApp() {
process.env.DB_PATH = dbPath;
const cached = [
require.resolve('../src/db'),
require.resolve('../src/config'),
require.resolve('../src/middleware/session'),
require.resolve('../src/services/authService'),
require.resolve('../src/app'),
require.resolve('../scripts/migrate'),
];
for (const id of cached) {
if (require.cache[id]) delete require.cache[id];
}
const { runMigrations } = require('../scripts/migrate');
runMigrations();
const { createApp } = require('../src/app');
return createApp();
}
async function setup() {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'emberfall-test-'));
dbPath = path.join(tmpDir, 'test.db');
const app = createFreshApp();
return new Promise((resolve, reject) => {
server = app.listen(0, () => {
const port = server.address().port;
resolve(`http://localhost:${port}`);
});
server.on('error', reject);
});
}
function teardown() {
return new Promise((resolve) => {
if (server) {
// Force-close all connections and the server to avoid hanging on session store interval
try { server.closeAllConnections(); } catch (_) {}
server.close(() => {
server = null;
resolve();
});
} else {
resolve();
}
});
}
function cleanup() {
if (tmpDir && fs.existsSync(tmpDir)) {
try { fs.rmSync(tmpDir, { recursive: true }); } catch (_) {}
}
tmpDir = undefined;
dbPath = undefined;
}
module.exports = { setup, teardown, cleanup };
View File
View File
+53
View File
@@ -0,0 +1,53 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { CLASSES, CLASS_KEYS, getClass, getBaseStats } = require('../../src/game/classes');
test('CLASSES contains exactly 3 classes', () => {
assert.equal(CLASS_KEYS.length, 3);
assert.ok(CLASS_KEYS.includes('warrior'));
assert.ok(CLASS_KEYS.includes('mage'));
assert.ok(CLASS_KEYS.includes('ranger'));
});
test('getClass returns correct base stats for warrior', () => {
const stats = getBaseStats('warrior');
assert.equal(stats.str, 8);
assert.equal(stats.agi, 4);
assert.equal(stats.int, 2);
assert.equal(stats.vit, 8);
});
test('getClass returns correct base stats for mage', () => {
const stats = getBaseStats('mage');
assert.equal(stats.str, 2);
assert.equal(stats.agi, 4);
assert.equal(stats.int, 9);
assert.equal(stats.vit, 5);
});
test('getClass returns correct base stats for ranger', () => {
const stats = getBaseStats('ranger');
assert.equal(stats.str, 5);
assert.equal(stats.agi, 9);
assert.equal(stats.int, 3);
assert.equal(stats.vit, 5);
});
test('getClass throws for unknown class', () => {
assert.throws(() => getClass('paladin'), Error);
});
test('getClass returns the class object with name and primary', () => {
const warrior = getClass('warrior');
assert.equal(warrior.name, 'Warrior');
assert.equal(warrior.primary, 'str');
assert.ok(warrior.bonuses.defensePct);
const mage = getClass('mage');
assert.equal(mage.primary, 'int');
assert.ok(mage.bonuses.magicVsHighDef);
const ranger = getClass('ranger');
assert.equal(ranger.primary, 'agi');
assert.ok(ranger.bonuses.critChance);
});
+90
View File
@@ -0,0 +1,90 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { computeEnergy, ENERGY_MAX, ENERGY_REGEN_SECONDS } = require('../../src/game/energy');
// No time passed
test('computeEnergy no time passed', () => {
const now = Date.now();
const result = computeEnergy(50, now, now);
assert.equal(result.energy, 50);
assert.equal(result.updatedAtMs, now);
});
// Clock going backwards
test('computeEnergy clock going backwards', () => {
const result = computeEnergy(50, Date.now(), Date.now() - 100000);
assert.equal(result.energy, 50);
});
// Less than one regen interval
test('computeEnergy partial tick (less than 1 regen interval)', () => {
const now = Date.now();
const result = computeEnergy(50, now - 10000, now);
// 10s < 180s → no regen
assert.equal(result.energy, 50);
});
// One full tick
test('computeEnergy one full tick', () => {
const now = Date.now();
const result = computeEnergy(50, now - 180000, now);
// 180s / 180s = 1 tick
assert.equal(result.energy, 51);
});
// Multiple full ticks
test('computeEnergy multiple full ticks', () => {
const now = Date.now();
const result = computeEnergy(50, now - 360000, now);
// 360s / 180s = 2 ticks
assert.equal(result.energy, 52);
});
// Caps at max
test('computeEnergy caps at max', () => {
const now = Date.now();
const result = computeEnergy(100, now - 3600000, now);
assert.equal(result.energy, ENERGY_MAX);
});
// Large gap (hours ago)
test('computeEnergy large gap caps at max', () => {
const now = Date.now();
const result = computeEnergy(30, now - 36000000, now); // 10 hours ago
assert.equal(result.energy, ENERGY_MAX);
});
// Partial interval preserved (timestamp advances by whole ticks only)
test('computeEnergy preserves partial interval', () => {
const updatedAt = 1000000;
const now = 1000000 + 180000 + 50000; // 1 tick + 50s partial
const result = computeEnergy(50, updatedAt, now);
assert.equal(result.energy, 51);
// Timestamp should advance by exactly 1 tick, not the full elapsed
assert.equal(result.updatedAtMs, updatedAt + 180000);
});
// Starting from zero
test('computeEnergy from zero', () => {
const now = Date.now();
const result = computeEnergy(0, now - 540000, now);
// 540s / 180s = 3 ticks → 0 + 3 = 3
assert.equal(result.energy, 3);
});
// At max, large gap → resets timestamp
test('computeEnergy at max resets timestamp', () => {
const now = Date.now();
const result = computeEnergy(100, now - 10000000, now);
assert.equal(result.energy, ENERGY_MAX);
assert.equal(result.updatedAtMs, now);
});
// Constants are correct
test('ENERGY_MAX is 100', () => {
assert.equal(ENERGY_MAX, 100);
});
test('ENERGY_REGEN_SECONDS is 180', () => {
assert.equal(ENERGY_REGEN_SECONDS, 180);
});
+138
View File
@@ -0,0 +1,138 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { calcDerived, computeEnergy, xpToNext, checkLevelUp, ENERGY_MAX } = require('../../src/game/stats');
// calcDerived tests
test('calcDerived warrior at level 1 with no equipment', () => {
const derived = calcDerived({ str: 8, agi: 4, int: 2, vit: 8 }, 1, 'warrior');
// maxHp = 50 + 8*10 + 1*5 = 135
assert.equal(derived.maxHp, 135);
// primary = str = 8, attack = 5 + 8*2 + 1 = 22
assert.equal(derived.attack, 22);
// defense = floor(8*1.5) = 12, +10% warrior = 13
assert.equal(derived.defense, 13);
// speed = 10 + 4 = 14
assert.equal(derived.speed, 14);
// critChance = min(40, 5 + floor(4*0.5)) = min(40, 7) = 7
assert.equal(derived.critChance, 7);
});
test('calcDerived mage at level 1 with no equipment', () => {
const derived = calcDerived({ str: 2, agi: 4, int: 9, vit: 5 }, 1, 'mage');
// maxHp = 50 + 5*10 + 1*5 = 105
assert.equal(derived.maxHp, 105);
// primary = int = 9, attack = 5 + 9*2 + 1 = 24
assert.equal(derived.attack, 24);
// defense = floor(5*1.5) = 7 (no warrior bonus)
assert.equal(derived.defense, 7);
// speed = 10 + 4 = 14
assert.equal(derived.speed, 14);
// critChance = min(40, 5 + floor(4*0.5)) = 7
assert.equal(derived.critChance, 7);
});
test('calcDerived ranger at level 1 with no equipment', () => {
const derived = calcDerived({ str: 5, agi: 9, int: 3, vit: 5 }, 1, 'ranger');
// maxHp = 50 + 5*10 + 1*5 = 105
assert.equal(derived.maxHp, 105);
// primary = agi = 9, attack = 5 + 9*2 + 1 = 24
assert.equal(derived.attack, 24);
// defense = floor(5*1.5) = 7
assert.equal(derived.defense, 7);
// speed = 10 + 9 = 19
assert.equal(derived.speed, 19);
// critChance = min(40, 5 + floor(9*0.5)) + 8 = min(40, 9) + 8 = 17
assert.equal(derived.critChance, 17);
});
test('calcDerived with equipment bonuses', () => {
const derived = calcDerived(
{ str: 8, agi: 4, int: 2, vit: 8 },
1,
'warrior',
{ bonus_str: 2, bonus_attack: 5, bonus_defense: 3 }
);
// totalStr = 10, attack = 5 + 10*2 + 1 + 5 = 31
assert.equal(derived.attack, 31);
// totalVit = 8, defense = floor(8*1.5) = 12, +3 equip = 15, +10% = 16
assert.equal(derived.defense, 16);
});
// computeEnergy tests
test('computeEnergy no time passed', () => {
const result = computeEnergy(50, Date.now(), Date.now() - 1000);
assert.equal(result.energy, 50);
});
test('computeEnergy partial tick (less than 1 regen interval)', () => {
const now = Date.now();
const result = computeEnergy(50, now - 10000, now);
// 10s < 180s → no regen
assert.equal(result.energy, 50);
});
test('computeEnergy full tick', () => {
const now = Date.now();
const result = computeEnergy(50, now - 360000, now);
// 360s / 180s = 2 ticks
assert.equal(result.energy, 52);
});
test('computeEnergy caps at max', () => {
const now = Date.now();
const result = computeEnergy(100, now - 3600000, now);
assert.equal(result.energy, ENERGY_MAX);
});
test('computeEnergy clock going backwards', () => {
const result = computeEnergy(50, Date.now(), Date.now() - 100000);
assert.equal(result.energy, 50);
});
test('computeEnergy large gap', () => {
const now = Date.now();
const result = computeEnergy(30, now - 36000000, now); // 10 hours ago
assert.equal(result.energy, ENERGY_MAX);
});
// xpToNext tests
test('xpToNext level 1→2 is 100', () => {
assert.equal(xpToNext(1), 100);
});
test('xpToNext level 2→3 is 282', () => {
assert.equal(xpToNext(2), 282);
});
test('xpToNext level 5→6 is 1118', () => {
assert.equal(xpToNext(5), 1118);
});
// checkLevelUp tests
test('checkLevelUp no level up', () => {
const result = checkLevelUp(1, 50, 10);
assert.equal(result.newLevel, 1);
assert.equal(result.leveledUp, false);
assert.equal(result.totalXp, 60);
});
test('checkLevelUp exact threshold', () => {
const result = checkLevelUp(1, 90, 10);
assert.equal(result.newLevel, 2);
assert.equal(result.leveledUp, true);
assert.equal(result.totalXp, 0);
});
test('checkLevelUp multiple level ups', () => {
// L1→2 costs 100, L2→3 costs 282. Total 382 for 2 levels. 500 XP → 2 levels, 118 remaining.
const result = checkLevelUp(1, 0, 500);
assert.equal(result.newLevel, 3);
assert.equal(result.leveledUp, true);
assert.equal(result.totalXp, 118);
});
test('checkLevelUp grants 3 points per level', () => {
const result = checkLevelUp(1, 0, 282); // exactly enough for L1→L2
assert.equal(result.newLevel, 2);
assert.equal(result.unspentPoints, 3);
});