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
+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`.