loop: baseline

This commit is contained in:
bepis
2026-10-20 20:33:35 +11:00
parent f1ae7b3119
commit 9a308b697d
32 changed files with 959 additions and 2046 deletions
+3
View File
@@ -5,3 +5,6 @@ data/*.db-journal
data/*.db-wal data/*.db-wal
data/*.db-shm data/*.db-shm
*.log *.log
# agent loop
.loop/
+50
View File
@@ -0,0 +1,50 @@
# HANDOFF.md — Session Handoff (OVERWRITE at the end of every phase or when context is getting long)
> **Read order for a new session:** `NOTES.md` → `STATUS.md` → this file → then work.
> If this file disagrees with the code, trust the code and fix this file.
## 1. Where we are
- **Last completed step:** 4.4 (hunt endpoint) + a full review/fix pass
- **Next step:** 4.5 — hunt UI (needs `GET /api/zones` first, see STATUS issue 2)
- **Updated:** 2026-10-20
- **Was the last session cut off mid-task?** No
## 2. Verified state (run these first)
| Command | Expected |
|---|---|
| `npm install` | succeeds (better-sqlite3 is a native module; needs Node 20+ and a C++ toolchain if no prebuilt binary) |
| `npm run migrate` | applies 001–004 and 006 / "nothing to do" |
| `npm test` | **93 passing, 0 failing** (69 unit + 24 integration) |
## 3. What exists now (files that matter)
- `src/game/stats.js` — `calcDerived()` is the ONLY derived-stat formula. Also re-exports xpToNext/checkLevelUp (implemented in `leveling.js`).
- `src/game/combat.js` — `resolveCombat(hero, monster, rng)`; both sides attack each round, faster first.
- `src/services/huntService.js` — `hunt(characterId, zoneId, {rng, now})`; ONE transaction; test-injectable.
- `src/services/characterService.js` — `createCharacter`, `getCharacterProfile`, `getCharacterIdForUser`.
- `src/utils/time.js` — `now()/setNow()/resetNow()`; never call `Date.now()` in services.
- `src/middleware/session.js` — real SQLite session store (timer unref'd).
- `src/db/migrations/003_content.sql` — also SEEDS zones/monsters/items/loot (INSERT OR IGNORE). Changing balance = NEW migration, never edit 003.
- `tests/helpers.js` — `setup()/teardown()/cleanup()`: fresh app + temp DB per call. `tests/integration/hunt-service.test.js` shows how to force outcomes with test-only zones.
## 4. Half-finished / fragile things
- No `GET /api/zones` yet; `public/js/game.js` has no hunt UI yet (4.5).
- HP never regenerates naturally; only level-up restores it (Rest/potions in Phases 5–6).
- No restart-persistence test for sessions.
## 5. Decisions & deviations from the docs
- D-004..D-007 in `docs/DECISIONS.md` (combat rounds, equipment counted once, session store, clock).
## 6. Gotchas learned
- `npm test` must be `node --test` with NO path/glob; files must be named `*.test.js`. A shell glob once skipped 17 tests.
- better-sqlite3-session-store ignores `clear:false`; that's why the timer is unref'd.
- Statement caches are per-DB-handle (WeakMap) in huntService; copy that pattern for new services.
- Verify test COUNT, not just "all passing".
## 7. Next-step briefing (4.5)
- **Goal:** zone list + Hunt button + combat log replay + result summary + level-up message on the dashboard.
- **Docs:** API.md §Hunting, UI_GUIDE.md §game.html, GAME_DESIGN.md §Combat.
- **Files:** add `src/routes/zones.js` (+ service fn) and mount in app.js; extend `public/game.html` + `public/js/game.js`; add an integration test for `GET /api/zones`.
- **Watch out for:** use `textContent` (no innerHTML); show energy cost on the button; disable button while request is in flight; re-sync energy from the hunt response (`data.energy`, `data.hpRemaining`, `data.hpMax`).
## 8. Open Known Issues
STATUS.md issues 1–6 (balance, zones endpoint, dangling `seed` script, restart test, equipment-in-combat, HP regen).
+6 -1
View File
@@ -29,7 +29,6 @@ simple, and correct beats ambitious and broken.
They take data in and return data out, so they can be unit tested. 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. 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. 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) ## 3. How to work (summary — details in docs/WORKFLOW.md)
1. Open `STATUS.md`. Find the first unchecked step in the lowest-numbered phase. 1. Open `STATUS.md`. Find the first unchecked step in the lowest-numbered phase.
@@ -62,3 +61,9 @@ 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 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 shop → start and claim an expedition → see the leaderboard → log out → log back
in and find everything exactly as left (plus offline energy regen). in and find everything exactly as left (plus offline energy regen).
## 8. Automated loop mode
This project may be driven by `scripts/run-agent-loop.sh`, which starts a fresh agent session per step.
In that mode: do exactly the one target step named in the prompt, never run commands that block forever,
never run `git commit`/`git reset` (the loop commits after it verifies `npm test`, and rolls back on failure),
and never delete or weaken tests (the loop rejects runs where the passing-test count drops).
+5 -1
View File
@@ -7,7 +7,8 @@ inside the current project folder. All specifications are in markdown files here
2. Work on the first unchecked step in `STATUS.md`. Read only the docs that phase references. 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. 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. 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. 5. Continue to the next step. Stop only when blocked or when the current phase is complete.
6. Do not paste large files or long test output into your reasoning; read only the lines you need.
Rules: obey the Hard Constraints in NOTES.md. Do not add dependencies without logging in 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 docs/DECISIONS.md. Do not skip updating STATUS.md. If docs are ambiguous, choose the
@@ -21,3 +22,6 @@ Begin with step 0.1.
- If it drifts, tell it: "Re-read STATUS.md and WORKFLOW.md, then 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. - 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. - Set a large context window (≥ 32k) in your runner; docs are intentionally split to keep each read small.
- New prompt:
"Read NOTES.md, STATUS.md, then HANDOFF.md. Run the verify commands listed in HANDOFF section 2 and confirm they match. Check files from the last step are complete, then continue from Current Focus in STATUS.md."
+31
View File
@@ -0,0 +1,31 @@
You are an autonomous engineer building the fantasy PBBG "Emberfall" in the current folder.
You are run by an automated loop. You do exactly ONE step per run, then you stop.
You have no memory of earlier runs: your briefing is the files on disk.
## Start (in this order)
1. Read `NOTES.md` (rules and constraints).
2. Read `STATUS.md` (find the target step named at the bottom of this prompt).
3. Read `HANDOFF.md` if it has real content. Treat it as a briefing; if it disagrees with the code, trust the code.
4. Read ONLY the docs the target step's phase refers to. Do not read every doc.
## Do the step
- Implement only the target step. Do NOT start the next step, even if time remains.
- Work in small pieces; run code/tests after each piece.
- Obey the Hard Constraints in NOTES.md (stack, layering, prepared statements, no innerHTML with dynamic data, no new dependencies without a note in docs/DECISIONS.md).
- Write at least one happy-path test and one failure-path test when the step adds behaviour.
- Never delete or weaken existing tests to make them pass.
## Headless rules (important: nobody can answer prompts)
- Never run a command that waits forever. To test the server, start it in the background
(`node src/server.js &`), `curl` it, then kill it (`kill %1`). Prefer integration tests.
- No interactive commands (no `npm init` without `-y`, no editors, no prompts).
- Do not paste whole files or long output into your reasoning; read only the lines you need.
- Do not run `git commit` or `git reset`. The loop handles git.
## Finish (all required)
1. Run `npm test` (if tests exist) and make sure it passes.
2. In `STATUS.md`: tick the target step ONLY if its Acceptance line is truly met; update Current Focus to the next step; update Project Health; add Known Issues for anything unresolved; add ONE Session Log line.
3. Overwrite `HANDOFF.md` using its template (keep it short and specific).
4. Reply with 3 lines max: what you did, test result, anything risky. Then stop.
If you are blocked, do NOT fake it. Do not tick the step. Describe the blocker in Known Issues and HANDOFF.md §4, then stop.
+4
View File
@@ -0,0 +1,4 @@
# Context for Qwen Code
This project (Emberfall, a fantasy PBBG) is specified in markdown files.
Read `NOTES.md` first, then `STATUS.md`, then `HANDOFF.md`. Follow `NOTES.md` rules strictly.
If running under the automated loop, the prompt names a single target step: do only that step.
+24 -13
View File
@@ -8,18 +8,18 @@
## Current Focus ## Current Focus
- **Phase:** 4 - **Phase:** 4
- **Step:** 4.4 - **Step:** 4.5
- **Blocked?** No - **Blocked?** No
- **Last verified working command:** `npm test` — 62/62 passing, Phase 4 step 4.4 complete - **Last verified working command:** `npm test` — 93/93 passing (69 unit + 24 integration), 2026-10-20 review/fix pass
## Project Health ## Project Health
| Item | State | | Item | State |
|---|---| |---|---|
| App starts (`npm start`) | ✅ works (session interval keeps process alive) | | App starts (`npm start`) | ✅ smoke-tested: register → create hero → 12 hunts → level-up (real seeded zones) |
| Tests pass (`npm test`) | ✅ 62/62 passing (4 hunt + 6 auth + 1 health + 51 unit) | | Tests pass (`npm test`) | ✅ 93/93 (runs EVERY *.test.js; auth/combat/health were previously skipped by a bad glob) |
| Migrations run clean on empty DB | ✅ 001_users, 002_characters, 003_content, 004_inventory, 006_logs | | Migrations run clean on empty DB | ✅ 001_users, 002_characters, 003_content (also seeds zones/monsters/items), 004_inventory, 006_logs (005 reserved for expeditions) |
| Can register + login | ✅ verified by tests | | Can register + login | ✅ verified by tests |
| Data persists across restart | ✅ SQLite-backed sessions | | Data persists across restart | ⚠️ sessions use the real SQLite store (also in tests); a restart test is still missing |
--- ---
@@ -59,12 +59,13 @@
*Verified:* energy bar + countdown in game.html/game.js; server returns nextTickInSec; 16 computeEnergy unit tests cover cap/partial/large-gap scenarios. *Verified:* energy bar + countdown in game.html/game.js; server returns nextTickInSec; 16 computeEnergy unit tests cover cap/partial/large-gap scenarios.
## Phase 4 — Combat (Hunting) (docs: GAME_DESIGN §Combat, BALANCE) ## Phase 4 — Combat (Hunting) (docs: GAME_DESIGN §Combat, BALANCE)
- [x] **4.1** Content tables + seed data (zones, monsters, items, loot). *Done inside migration `003_content.sql` (INSERT OR IGNORE), not `scripts/seed.js`. Balance changes need a NEW migration.*
- [x] **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. - [x] **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.
*Acceptance:* 10 combat unit tests pass; deterministic with same seed; speed determines turn order; mage bonus vs high-def; 20-round cap. *Acceptance:* 10 combat unit tests pass; deterministic with same seed; speed determines turn order; mage bonus vs high-def; 20-round cap.
- [x] **4.3** `src/game/rewards.js`: XP, gold, loot roll. `src/game/leveling.js`: XP curve, level-up, stat points. - [x] **4.3** `src/game/rewards.js`: XP, gold, loot roll. `src/game/leveling.js`: XP curve, level-up, stat points.
*Acceptance:* 23 unit tests pass; xpToNext matches BALANCE.md; level-up handles multi-level; points allocation validated. *Acceptance:* 23 unit tests pass; xpToNext matches BALANCE.md; level-up handles multi-level; points allocation validated.
- [ ] **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. - [x] **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.
*Acceptance:* 4 integration tests pass; hunt returns winner, rounds, xpGained, goldGained, loot, leveledUp, zone, monster. *Acceptance:* hunt returns winner, rounds, xpGained, goldGained, loot, leveledUp, zone, monster. *Verified:* 9 service tests (`tests/integration/hunt-service.test.js`) cover persistence, level-up, defeat, locked zone, energy, stacking.
- [ ] **4.5** UI: zone list, "Hunt" button, combat log display, result summary, level-up message. - [ ] **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. *Acceptance:* hunting repeatedly drains energy, awards XP/gold, levels the hero, and persists.
@@ -112,27 +113,37 @@
## Known Issues ## Known Issues
| # | Area | Description | Severity | Status | Found in step | | # | Area | Description | Severity | Status | Found in step |
|---|------|-------------|----------|--------|---------------| |---|------|-------------|----------|--------|---------------|
| _none yet_ | | | | | | | 1 | Balance | Level-1 heroes of every class win 100% of Meadows fights (design target ≈80%) and lose 100% of Crags fights. Tune monster stats via a NEW migration. | minor | open | 4.4 review |
| 2 | API | `GET /api/zones` (list zones + `unlocked`) is documented in API.md but not implemented; step 4.5 needs it. | major | open | 4.4 review |
| 3 | Scripts | `npm run seed` points at `scripts/seed.js`, which doesn't exist (seeds are in migration 003). Remove the script or add the file. | cosmetic | open | 0.1 |
| 4 | Tests | No test proves sessions survive a server restart (two apps on one DB file). | minor | open | 1.3 |
| 5 | Combat | Hero equipment bonuses are not passed into combat yet (Phase 5). `resolveCombat` already accepts `bonus_*` fields. | minor | open | 5.x |
| 6 | Combat | Natural HP regeneration is not implemented: HP only restores on level-up (rest/potions arrive in Phases 5–6). | minor | open | 4.4 |
| 7 | Combat/Hunt | (Fixed in review) only the faster side attacked; mage bonus never applied; level-up kept XP and overwrote stat points; deaths never counted; zone min_level ignored; stackable loot duplicated rows; energy countdown showed time-to-full. | blocker | fixed | review |
Severity: `blocker` / `major` / `minor` / `cosmetic`. Status: `open` / `fixing` / `wontfix` / `fixed`. Severity: `blocker` / `major` / `minor` / `cosmetic`. Status: `open` / `fixing` / `wontfix` / `fixed`.
## Technical Debt / TODOs ## Technical Debt / TODOs
- _none yet_ - `wrap()` async helper is copy-pasted in each route file; move to `src/middleware/wrap.js`.
- Error responses from route-level `AppError`s for validation don't include field details (API.md says they should).
- `npm test` prints a lot of TAP noise; consider `--test-reporter=spec`.
- `src/db/seeds/` is empty (kept for later content seeds).
## Open Questions (for the human) ## Open Questions (for the human)
- _none yet_ - _none yet_
## Decisions Made (short; long form goes in docs/DECISIONS.md) ## Decisions Made (short; long form goes in docs/DECISIONS.md)
- _none yet_ - D-004: every combat round BOTH sides attack (faster first). D-005: equipment stats counted once in derived stats. D-006: unref the session-store cleanup timer; use the real SQLite store in tests. D-007: `utils/time.js` is the only clock.
## Test Summary ## Test Summary
| Suite | Passing | Failing | Last run | | Suite | Passing | Failing | Last run |
|---|---|---|---| |---|---|---|---|
| unit | 51 | 0 | Phase 4.4 | | unit | 69 | 0 | 2026-10-20 review pass |
| integration | 7 | 0 | Phase 4.4 | | integration | 24 | 0 | 2026-10-20 review pass |
## Session Log ## Session Log
_Format:_ `YYYY-MM-DD | step | what changed | result` _Format:_ `YYYY-MM-DD | step | what changed | result`
- 2026-10-20 | review | Fixed `npm test` (ran 62 of 79 tests; auth/combat/health were skipped by an unquoted glob). Rewrote combat (slower side never attacked; mage bonus dead; hpRemaining wrong on loss). Rewrote huntService (XP not deducted on level-up, unspent points overwritten, deaths never counted, min_level ignored, stackable loot duplicated, duplicate stat formulas). Fixed nextTickInSec (was time-to-full). Added utils/time.js, WeakMap stmt cache, session store unref, ok:false error envelope, removed connect-sqlite3 | 93/93 tests pass; smoke test OK
- 2026-10-20 | 4.4 | Created 004_inventory.sql + 006_logs.sql migrations; fixed huntService transaction (db.transaction() returns wrapper, must call separately); added huntService to helpers.js cache invalidation; session middleware uses memory store in tests | 62/62 tests pass, hunt endpoint working - 2026-10-20 | 4.4 | Created 004_inventory.sql + 006_logs.sql migrations; fixed huntService transaction (db.transaction() returns wrapper, must call separately); added huntService to helpers.js cache invalidation; session middleware uses memory store in tests | 62/62 tests pass, hunt endpoint working
- 2026-10-20 | 3.3 | Fixed game.js countdown: separated updateEnergyBar (pure display) from startCountdown (client-side timer), added energyData store and startEnergySync (60s server refetch) | 35/35 tests pass, energy bar + countdown working correctly - 2026-10-20 | 3.3 | Fixed game.js countdown: separated updateEnergyBar (pure display) from startCountdown (client-side timer), added energyData store and startEnergySync (60s server refetch) | 35/35 tests pass, energy bar + countdown working correctly
- 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.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
+2 -2
View File
@@ -27,7 +27,7 @@ On level-up: `hp_current = maxHp`, loop to handle multiple level-ups from one re
## Derived stats ## Derived stats
``` ```
maxHp = 50 + vit*10 + level*5 + itemBonusVit*10 maxHp = 50 + totalVit*10 + level*5 // totalVit = base vit + equipment vit (counted ONCE)
primary = STR | INT | AGI (by class) primary = STR | INT | AGI (by class)
attack = 5 + primary*2 + level + equipment.bonus_attack attack = 5 + primary*2 + level + equipment.bonus_attack
defense = floor(vit*1.5) + equipment.bonus_defense (+10% if Warrior) defense = floor(vit*1.5) + equipment.bonus_defense (+10% if Warrior)
@@ -35,7 +35,7 @@ speed = 10 + agi
critChance% = min(40, 5 + floor(agi*0.5)) (+8 if Ranger) critChance% = min(40, 5 + floor(agi*0.5)) (+8 if Ranger)
critMult = 1.5 critMult = 1.5
``` ```
Equipment `bonus_str/agi/int/vit` are added to the base stats BEFORE the derived formulas. Equipment `bonus_str/agi/int/vit` are added to the base stats BEFORE the derived formulas, and nowhere else (never count an equipment bonus twice).
## Damage ## Damage
``` ```
+18 -1
View File
@@ -31,4 +31,21 @@ Format:
- Decision: Static HTML/CSS/JS served by Express. - Decision: Static HTML/CSS/JS served by Express.
- Consequences: Slightly more DOM boilerplate; far fewer moving parts. - Consequences: Slightly more DOM boilerplate; far fewer moving parts.
<!-- Add D-004 onward below --> ## D-004: Both combatants attack every round
- Date: 2026-10-20 (review)
- Context: Combat treated "higher speed acts first" as "only the faster side acts", so the slower side never attacked.
- Decision: Each round, both attack; faster first; hero wins speed ties; a kill ends the round.
- Consequences: Round log has up to 2 entries per round (max 40). Win rates need re-tuning (see STATUS issue 1).
## D-005: Equipment stats are counted once in derived stats
- Context: maxHp added equipment VIT twice (once in totalVit, once as a separate term).
- Decision: `calcDerived` in `src/game/stats.js` is the ONLY derived-stat formula; combat and services call it.
## D-006: Real SQLite session store everywhere; unref its cleanup timer
- Context: better-sqlite3-session-store ignores `expired.clear:false` (`clear || true`), so its setInterval kept tests alive; the project had used a memory store in tests, so the real store was never exercised.
- Decision: Wrap store creation so its interval is unref'd. Tests now use the real store.
## D-007: `src/utils/time.js` is the only clock
- Decision: Services call `time.now()`; tests use `time.setNow()`. Game functions take `nowMs`/`rng` as parameters.
<!-- Add D-008 onward below -->
+1 -1
View File
@@ -39,7 +39,7 @@ Each level grants **3 unspent stat points** (player allocates via API).
- Derived stats formulas: see BALANCE.md §Derived. - Derived stats formulas: see BALANCE.md §Derived.
## Combat (auto-resolved, server-side, turn based) ## Combat (auto-resolved, server-side, turn based)
- Hero vs one monster, alternating attacks. Higher `speed` acts first each round. - Hero vs one monster. **Each round BOTH sides attack once**; the higher `speed` side attacks first (hero wins ties). If the first attacker kills the target, the second does not act.
- Max 20 rounds; if no one dies, the one with higher HP% wins. - 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. - 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. - Output a **round-by-round log** (array of `{round, actor, damage, crit, targetHpAfter}`) for the UI to replay.
+6 -1
View File
@@ -1,7 +1,9 @@
# TESTING.md # TESTING.md
Runner: built-in `node:test` + `node:assert/strict`. No Jest/Mocha. Runner: built-in `node:test` + `node:assert/strict`. No Jest/Mocha.
Script: `"test": "node --test tests/"` (add `--test-concurrency=1` if DB tests collide). Script: `"test": "NODE_ENV=test node --test --test-concurrency=1"`.
**Do NOT pass a directory or an unquoted glob** (`node --test tests/` finds nothing on Node 21+; `tests/**/*.test.js` is expanded by `sh` and skips top-level files). Plain `node --test` auto-discovers every `*.test.js`.
Test files MUST be named `*.test.js`.
## Layout ## Layout
- `tests/unit/` — pure functions in `src/game` and `src/utils`. No DB, no HTTP. - `tests/unit/` — pure functions in `src/game` and `src/utils`. No DB, no HTTP.
@@ -37,6 +39,9 @@ Inject time: tests set `time.setNow(fn)` to fast-forward energy/expeditions inst
`describe('energy.computeEnergy', () => { it('caps at max', ...) })`. `describe('energy.computeEnergy', () => { it('caps at max', ...) })`.
## Rules ## Rules
- Run `npm test` and check the TOTAL against the sum of tests in all `*.test.js` files. A bad glob once silently skipped 17 tests.
- Integration tests use `setup()` from `tests/helpers.js`, which clears every `src/` module from the require cache so each test gets a fresh app and DB. Never hand-pick modules to clear.
- Force game outcomes with test-only zones/monsters and the `{ rng, now }` options of services — don't rely on luck, and don't weaken assertions to "winner is hero or monster".
- A failing test is never deleted to make the suite green. Fix the code or log a Known Issue. - 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. - New bug → write the failing test first, then fix.
- Update the Test Summary table in STATUS.md after each phase. - Update the Test Summary table in STATUS.md after each phase.
+1 -1415
View File
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -10,7 +10,7 @@
"scripts": { "scripts": {
"start": "node src/server.js", "start": "node src/server.js",
"dev": "node --watch src/server.js", "dev": "node --watch src/server.js",
"test": "NODE_ENV=test node --test --test-concurrency=1 tests/**/*.test.js", "test": "NODE_ENV=test node --test --test-concurrency=1",
"migrate": "node scripts/migrate.js", "migrate": "node scripts/migrate.js",
"seed": "node scripts/seed.js" "seed": "node scripts/seed.js"
}, },
@@ -20,7 +20,6 @@
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"better-sqlite3": "^13.0.3", "better-sqlite3": "^13.0.3",
"better-sqlite3-session-store": "^0.1.0", "better-sqlite3-session-store": "^0.1.0",
"connect-sqlite3": "^0.9.18",
"express": "^5.3.0", "express": "^5.3.0",
"express-rate-limit": "^8.7.1", "express-rate-limit": "^8.7.1",
"express-session": "^1.19.0", "express-session": "^1.19.0",
+80 -42
View File
@@ -1,6 +1,82 @@
// Game dashboard: load character profile and populate stats. // Game dashboard: load character profile and populate stats.
let energyInterval = null; let energyInterval = null;
let energyData = null; // store latest server energy data for sync
/**
* Refresh energy state from the server at a fixed interval.
* Keeps client-side countdown in sync with the authoritative server.
*/
function startEnergySync() {
if (energyInterval) clearInterval(energyInterval);
// Sync every 60s (2/3 of a regen tick) to stay in sync with server
energyInterval = setInterval(() => {
loadCharacter();
}, 60000);
}
/**
* Update the energy bar and countdown display.
* @param {number} current - current energy value
* @param {number} max - max energy
* @param {number} nextTickSec - seconds until next +1 energy tick (server-derived)
*/
function updateEnergyBar(current, max, nextTickSec) {
energyData = { 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 a client-side countdown that decrements until the next energy tick.
* When it reaches zero, refetch from server to get authoritative state.
*/
function startCountdown() {
if (!energyData || energyData.current >= energyData.max || energyData.nextTickSec <= 0) {
return;
}
let remaining = energyData.nextTickSec;
if (energyInterval) clearInterval(energyInterval);
energyInterval = setInterval(() => {
remaining--;
if (remaining <= 0) {
clearInterval(energyInterval);
energyInterval = null;
loadCharacter();
return;
}
const mins = Math.floor(remaining / 60);
const secs = remaining % 60;
const timerEl = document.getElementById('energy-timer');
if (timerEl) {
timerEl.textContent = `Next +1 in ${mins}:${secs.toString().padStart(2, '0')}`;
}
}, 1000);
}
async function loadCharacter() { async function loadCharacter() {
try { try {
@@ -35,8 +111,9 @@ async function loadCharacter() {
document.getElementById('derived-spd').textContent = derived.speed; document.getElementById('derived-spd').textContent = derived.speed;
document.getElementById('derived-crit').textContent = derived.critChance; document.getElementById('derived-crit').textContent = derived.critChance;
// Update energy bar // Update energy bar and start countdown
updateEnergyBar(energy.current, energy.max, energy.nextTickInSec); updateEnergyBar(energy.current, energy.max, energy.nextTickInSec);
startCountdown();
} catch (err) { } catch (err) {
// Not authenticated — redirect to login // Not authenticated — redirect to login
window.location.href = '/login.html'; window.location.href = '/login.html';
@@ -47,47 +124,8 @@ function xpToNext(level) {
return Math.floor(100 * Math.pow(level, 1.5)); return Math.floor(100 * Math.pow(level, 1.5));
} }
function updateEnergyBar(current, max, nextTickSec) { // Start periodic energy sync from server (every 60s)
// Update text startEnergySync();
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 // Logout handler
document.getElementById('logout-btn')?.addEventListener('click', async (e) => { document.getElementById('logout-btn')?.addEventListener('click', async (e) => {
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env bash
# run-agent-loop.sh — drives Qwen Code one STATUS.md step at a time.
# The script (not the model) decides: what's next, whether tests pass, commit vs rollback, when to stop.
#
# Usage: ./scripts/run-agent-loop.sh
# Options (env vars, all optional):
# AGENT_CMD agent command; prompt is appended as last arg (default: "qwen --yolo -p")
# MAX_STEPS max agent runs in total (default: 60)
# MAX_RETRIES attempts per step before giving up (default: 3)
# STEP_TIMEOUT per-run time limit, e.g. 30m, 1h (default: 45m)
# PAUSE_AT_PHASE 1 = wait for Enter between phases, 0 = don't (default: 1)
# STOP_AFTER_PHASE stop once this phase number is finished (default: run to the end)
# PROMPT_FILE prompt template (default: PROMPT_ONE_STEP.md)
#
# WARNING: --yolo lets the agent run ANY shell command without asking.
# Run inside this project folder only, ideally in a VM/container, with git initialised.
set -u
cd "$(dirname "$0")/.."
AGENT_CMD="${AGENT_CMD:-qwen --yolo -p}"
MAX_STEPS="${MAX_STEPS:-60}"
MAX_RETRIES="${MAX_RETRIES:-3}"
STEP_TIMEOUT="${STEP_TIMEOUT:-45m}"
PAUSE_AT_PHASE="${PAUSE_AT_PHASE:-1}"
STOP_AFTER_PHASE="${STOP_AFTER_PHASE:-}"
PROMPT_FILE="${PROMPT_FILE:-PROMPT_ONE_STEP.md}"
LOOP_DIR=".loop"; LOG_DIR="$LOOP_DIR/logs"
FEEDBACK="$LOOP_DIR/feedback.md"; PASS_FILE="$LOOP_DIR/pass_count"
mkdir -p "$LOG_DIR"
read -ra AGENT <<< "$AGENT_CMD"
# --- helpers ---------------------------------------------------------------
if command -v timeout >/dev/null 2>&1; then TO=timeout
elif command -v gtimeout >/dev/null 2>&1; then TO=gtimeout
else TO=""; echo "note: no 'timeout' command found; runs will not be time-limited"; fi
with_timeout() { local t="$1"; shift; if [ -n "$TO" ]; then "$TO" "$t" "$@"; else "$@"; fi; }
# First unchecked numbered step (e.g. 1.5). Stretch goals (S1...) are ignored on purpose.
next_step() { sed -n 's/^- \[ \] \*\*\([0-9][0-9]*\.[0-9][0-9]*\)\*\*.*/\1/p' STATUS.md | head -1; }
total_steps() { grep -cE '^- \[[ xX]\] \*\*[0-9]+\.[0-9]+\*\*' STATUS.md; }
TEST_OUT=""; TEST_PASS=""
run_tests() { # returns 0 = ok/skipped, 1 = failed
TEST_OUT=""; TEST_PASS=""
if [ -f package.json ] && grep -q '"test"' package.json \
&& find tests -name '*.js' 2>/dev/null | grep -q .; then
TEST_OUT="$(with_timeout 5m npm test 2>&1)" || { return 1; }
TEST_PASS="$(printf '%s\n' "$TEST_OUT" | grep -E '^(ℹ|#) pass ' | tail -1 | awk '{print $3}')"
# guard: the number of passing tests must never drop (stops "fix" by deleting tests)
if [ -n "$TEST_PASS" ] && [ -f "$PASS_FILE" ]; then
local prev; prev="$(cat "$PASS_FILE")"
if [ "$TEST_PASS" -lt "$prev" ] 2>/dev/null; then
TEST_OUT="Passing test count dropped from $prev to $TEST_PASS. Do not delete or weaken tests. Restore them.
$TEST_OUT"
return 1
fi
fi
fi
if [ -x scripts/smoke.sh ]; then # optional extra check you can add later
TEST_OUT="$TEST_OUT
$(with_timeout 2m ./scripts/smoke.sh 2>&1)" || return 1
fi
return 0
}
rollback() { git reset -q --hard HEAD && git clean -qfd; }
commit() { git add -A && git commit -qm "$1" 2>/dev/null || true; }
# --- preflight -------------------------------------------------------------
[ -f STATUS.md ] && [ -f "$PROMPT_FILE" ] || { echo "Run from the project; STATUS.md and $PROMPT_FILE needed."; exit 2; }
command -v "${AGENT[0]}" >/dev/null 2>&1 || { echo "Agent command '${AGENT[0]}' not found."; exit 2; }
[ "$(total_steps)" -ge 1 ] || { echo "No numbered steps found in STATUS.md (format: '- [ ] **1.1** ...')."; exit 2; }
git rev-parse --git-dir >/dev/null 2>&1 || git init -q
# make sure commits can't silently fail (no identity configured is a common cause)
git config user.email >/dev/null 2>&1 || git config user.email "loop@localhost"
git config user.name >/dev/null 2>&1 || git config user.name "agent-loop"
git add -A && git commit -qm "loop: baseline" 2>/dev/null
git rev-parse --verify HEAD >/dev/null 2>&1 || { echo "Could not create a git baseline commit; aborting (rollback needs one)."; exit 2; }
trap 'echo; echo "Interrupted. Working tree left as-is (uncommitted changes may exist)."; exit 130' INT
# --- main loop -------------------------------------------------------------
last_step=""; attempt=0; prev_phase=""
echo "Loop starting. Logs in $LOG_DIR/. Ctrl+C to stop."
for ((n = 1; n <= MAX_STEPS; n++)); do
step="$(next_step)"
if [ -z "$step" ]; then echo "✅ All numbered steps are ticked. Done."; exit 0; fi
phase="${step%%.*}"
if [ -n "$STOP_AFTER_PHASE" ] && [ "$phase" -gt "$STOP_AFTER_PHASE" ]; then
echo "✅ Reached STOP_AFTER_PHASE=$STOP_AFTER_PHASE. Next up would be step $step."; exit 0
fi
if [ -n "$prev_phase" ] && [ "$phase" != "$prev_phase" ] && [ "$PAUSE_AT_PHASE" = "1" ]; then
echo; echo "── Phase $prev_phase complete (HEAD: $(git log -1 --format=%s)). Playtest / review now."
read -r -p "Press Enter to start phase $phase (Ctrl+C to stop)… " _ || true
fi
prev_phase="$phase"
if [ "$step" = "$last_step" ]; then attempt=$((attempt + 1)); else attempt=1; fi
last_step="$step"
if [ "$attempt" -gt "$MAX_RETRIES" ]; then
echo "🛑 Stuck on step $step after $MAX_RETRIES attempts. See $LOG_DIR/step-$step-try-*.log and $FEEDBACK"
exit 1
fi
prompt="$(cat "$PROMPT_FILE")
## This run
Target step: **$step** (attempt $attempt of $MAX_RETRIES)"
[ -s "$FEEDBACK" ] && prompt="$prompt
## Feedback from the previous attempt (fix this first)
$(cat "$FEEDBACK")"
log="$LOG_DIR/step-$step-try-$attempt.log"
echo; echo "▶ [$n/$MAX_STEPS] step $step, attempt $attempt → $log"
with_timeout "$STEP_TIMEOUT" "${AGENT[@]}" "$prompt" < /dev/null 2>&1 | tee "$log"
rc=${PIPESTATUS[0]}
note=""; [ "$rc" = "124" ] && note="The previous attempt TIMED OUT after $STEP_TIMEOUT. Do less per run; avoid commands that never exit (e.g. 'npm start' in the foreground)."
after="$(next_step)"
if run_tests; then
[ -n "$TEST_PASS" ] && echo "$TEST_PASS" > "$PASS_FILE"
if [ "$after" != "$step" ]; then
commit "step $step: done"; : > "$FEEDBACK"
echo "✔ step $step verified and committed (tests: ${TEST_PASS:-n/a})"
else
commit "step $step: wip (not ticked)"
printf '%s\nCode exists and tests pass, but step %s is NOT ticked in STATUS.md. Check the Acceptance line; if truly met, tick it (and update Session Log + HANDOFF.md). If not, finish the missing work.\n' "$note" "$step" > "$FEEDBACK"
echo "… step $step not ticked; keeping work, retrying"
fi
else
rollback
{ [ -n "$note" ] && echo "$note"; echo "Your last attempt was rolled back because verification FAILED. Output (tail):"; echo '```'; printf '%s\n' "$TEST_OUT" | tail -40; echo '```'; } > "$FEEDBACK"
echo "✘ step $step failed verification; rolled back"
fi
done
echo "Hit MAX_STEPS=$MAX_STEPS. Re-run to continue."; exit 0
+3
View File
@@ -53,6 +53,7 @@ function createApp() {
if (err instanceof AppError) { if (err instanceof AppError) {
return res.status(err.status).json({ return res.status(err.status).json({
ok: false,
error: { error: {
code: err.code, code: err.code,
message: err.message, message: err.message,
@@ -63,6 +64,7 @@ function createApp() {
// Check for Zod-like validation errors // Check for Zod-like validation errors
if (err.name === 'ZodError' || (err.errors && Array.isArray(err.errors))) { if (err.name === 'ZodError' || (err.errors && Array.isArray(err.errors))) {
return res.status(400).json({ return res.status(400).json({
ok: false,
error: { error: {
code: 'VALIDATION_ERROR', code: 'VALIDATION_ERROR',
message: 'Request validation failed.', message: 'Request validation failed.',
@@ -72,6 +74,7 @@ function createApp() {
// Generic error // Generic error
res.status(500).json({ res.status(500).json({
ok: false,
error: { error: {
code: 'INTERNAL_ERROR', code: 'INTERNAL_ERROR',
message: 'Something went wrong.', message: 'Something went wrong.',
+59 -86
View File
@@ -1,111 +1,84 @@
// Combat resolution: pure function based on GAME_DESIGN.md §Combat and BALANCE.md. // Combat resolution: pure function based on GAME_DESIGN.md §Combat and BALANCE.md.
// Takes hero data + monster data + rng → returns fight result. // Takes hero data + monster data + rng → returns fight result. No I/O, no clock.
//
// Each ROUND, BOTH sides attack once. The higher-speed side attacks first (hero wins ties).
// If the first attacker kills the target, the second does not get to act.
const { getClass } = require('./classes'); const { getClass } = require('./classes');
const { calcDerived } = require('./stats');
const MAX_ROUNDS = 20;
const MONSTER_CRIT_CHANCE = 5; // flat %
const CRIT_MULT = 1.5;
const MAGE_HIGH_DEF_THRESHOLD = 15;
/** /**
* Resolve a combat encounter between a hero and a monster. * @param {object} hero - { class, level, hpCurrent, str, agi, int, vit, bonus_* (optional equipment bonuses) }
* * @param {object} monster - { name, level, hp, attack, defense, speed }
* @param {object} hero - { class, level, hpCurrent, maxHp, str, agi, int, vit, bonus_attack, bonus_defense } * @param {function} rng - PRNG returning a float in [0, 1)
* @param {object} monster - { level, hp, attack, defense, speed, name } * @returns {{winner: 'hero'|'monster', rounds: object[], hpRemaining: number}}
* @param {function} rng - PRNG function returning float in [0, 1) * hpRemaining is ALWAYS the hero's remaining HP (0 if the hero died).
* @returns {object} { winner: 'hero'|'monster', rounds: [{round, actor, damage, crit, targetHpAfter}], hpRemaining }
*/ */
function resolveCombat(hero, monster, rng) { function resolveCombat(hero, monster, rng) {
const heroStats = calcHeroStats(hero); const stats = calcDerived(
{ str: hero.str, agi: hero.agi, int: hero.int, vit: hero.vit },
hero.level, hero.class, hero
);
const cls = getClass(hero.class);
const heroC = {
type: 'hero',
attack: stats.attack, defense: stats.defense, speed: stats.speed,
critChance: stats.critChance,
magicVsHighDef: cls.bonuses.magicVsHighDef || 0,
};
const monsterC = {
type: 'monster',
attack: monster.attack, defense: monster.defense, speed: monster.speed,
critChance: MONSTER_CRIT_CHANCE,
magicVsHighDef: 0,
};
let heroHp = hero.hpCurrent; let heroHp = hero.hpCurrent;
let monsterHp = monster.hp; let monsterHp = monster.hp;
const rounds = []; const rounds = [];
const order = heroC.speed >= monsterC.speed ? [heroC, monsterC] : [monsterC, heroC];
for (let round = 1; round <= 20; round++) { for (let round = 1; round <= MAX_ROUNDS; round++) {
// Determine who acts first (higher speed) for (const attacker of order) {
const heroActsFirst = heroStats.speed >= monster.speed; const defender = attacker === heroC ? monsterC : heroC;
const attacker = heroActsFirst ? { stats: heroStats, type: 'hero', name: 'Hero' } const { damage, crit } = resolveAttack(attacker, defender, rng);
: { stats: monsterStats(monster), type: 'monster', name: monster.name };
const defender = attacker.type === 'hero'
? { stats: monsterStats(monster), type: 'monster', name: monster.name }
: { stats: heroStats, type: 'hero', name: 'Hero' };
// Resolve attack if (attacker === heroC) {
const result = resolveAttack(attacker, defender, rng); monsterHp -= damage;
rounds.push({ round, actor: 'hero', damage, crit, targetHpAfter: Math.max(0, monsterHp) });
if (attacker.type === 'hero') { if (monsterHp <= 0) return { winner: 'hero', rounds, hpRemaining: heroHp };
monsterHp -= result.damage; } else {
rounds.push({ round, actor: 'hero', damage: result.damage, crit: result.crit, targetHpAfter: Math.max(0, monsterHp) }); heroHp -= damage;
if (monsterHp <= 0) { rounds.push({ round, actor: 'monster', damage, crit, targetHpAfter: Math.max(0, heroHp) });
return { winner: 'hero', rounds, hpRemaining: heroHp }; if (heroHp <= 0) return { winner: 'monster', rounds, hpRemaining: 0 };
}
} else {
heroHp -= result.damage;
rounds.push({ round, actor: 'monster', damage: result.damage, crit: result.crit, targetHpAfter: Math.max(0, heroHp) });
if (heroHp <= 0) {
return { winner: 'monster', rounds, hpRemaining: monsterHp };
} }
} }
} }
// 20 rounds — higher HP% wins // Round cap reached: higher remaining HP% wins (ties go to the hero).
const heroPct = heroHp / heroStats.maxHp; const heroPct = heroHp / stats.maxHp;
const monsterPct = monsterHp / monster.hp; const monsterPct = monsterHp / monster.hp;
if (heroPct >= monsterPct) { return { winner: heroPct >= monsterPct ? 'hero' : 'monster', rounds, hpRemaining: heroHp };
return { winner: 'hero', rounds, hpRemaining: heroHp };
}
return { winner: 'monster', rounds, hpRemaining: monsterHp };
}
function calcHeroStats(hero) {
const cls = getClass(hero.class);
const primary = cls.primary === 'str' ? hero.str
: cls.primary === 'agi' ? hero.agi
: hero.int;
const maxHp = 50 + hero.vit * 10 + hero.level * 5 + (hero.bonus_vit || 0) * 10;
const attack = 5 + primary * 2 + hero.level + (hero.bonus_attack || 0);
let defense = Math.floor(hero.vit * 1.5) + (hero.bonus_defense || 0);
if (cls.bonuses.defensePct) {
defense = Math.floor(defense * (1 + cls.bonuses.defensePct));
}
const speed = 10 + hero.agi;
let critChance = Math.min(40, 5 + Math.floor(hero.agi * 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 };
}
function monsterStats(monster) {
return {
attack: monster.attack,
defense: monster.defense,
speed: monster.speed,
critChance: 5, // Monsters crit at flat 5%
critMult: 1.5,
};
} }
function resolveAttack(attacker, defender, rng) { function resolveAttack(attacker, defender, rng) {
// Mitigation const mitigation = 100 / (100 + defender.defense);
const mitigation = 100 / (100 + defender.stats.defense); let raw = attacker.attack * mitigation;
// Raw damage if (attacker.magicVsHighDef && defender.defense >= MAGE_HIGH_DEF_THRESHOLD) {
let raw = attacker.stats.attack * mitigation; raw *= 1 + attacker.magicVsHighDef;
// Mage bonus: if defender is hero with class mage and defenderDefense >= 15
if (attacker.type === 'hero' && attacker.stats.primary === 'int' && defender.stats.defense >= 15) {
raw = raw * 1.15;
} }
// Variance: [0.9, 1.1] const variance = rng() * 0.2 + 0.9; // [0.9, 1.1)
const variance = rng() * 0.2 + 0.9; const crit = rng() * 100 < attacker.critChance;
const damage = Math.max(1, Math.round(raw * variance * (crit ? CRIT_MULT : 1)));
// Crit check return { damage, crit };
const isCrit = rng() * 100 < attacker.stats.critChance;
const critMult = isCrit ? (attacker.stats.critMult || 1.5) : 1;
const damage = Math.max(1, Math.round(raw * variance * critMult));
return { damage, crit: isCrit };
} }
module.exports = { resolveCombat }; module.exports = { resolveCombat, MAX_ROUNDS };
+4 -40
View File
@@ -3,6 +3,7 @@
const { getClass } = require('./classes'); const { getClass } = require('./classes');
const { computeEnergy: _computeEnergy, ENERGY_MAX, ENERGY_REGEN_SECONDS } = require('./energy'); const { computeEnergy: _computeEnergy, ENERGY_MAX, ENERGY_REGEN_SECONDS } = require('./energy');
const { xpToNext, checkLevelUp } = require('./leveling'); // single implementation lives in leveling.js
/** /**
* Calculate derived stats from base stats, level, and equipment bonuses. * Calculate derived stats from base stats, level, and equipment bonuses.
@@ -26,8 +27,8 @@ function calcDerived(stats, level, classKey, equipBonuses = {}) {
: cls.primary === 'agi' ? totalAgi : cls.primary === 'agi' ? totalAgi
: totalInt; : totalInt;
// maxHp = 50 + vit*10 + level*5 + itemBonusVit*10 // maxHp = 50 + totalVit*10 + level*5 (totalVit already includes equipment VIT, so it is counted once)
const maxHp = 50 + totalVit * 10 + level * 5 + (equipBonuses.bonus_vit || 0) * 10; const maxHp = 50 + totalVit * 10 + level * 5;
// attack = 5 + primary*2 + level + equipment.bonus_attack // attack = 5 + primary*2 + level + equipment.bonus_attack
const attack = 5 + primary * 2 + level + (equipBonuses.bonus_attack || 0); const attack = 5 + primary * 2 + level + (equipBonuses.bonus_attack || 0);
@@ -55,6 +56,7 @@ function calcDerived(stats, level, classKey, equipBonuses = {}) {
critChance, critChance,
critMult: 1.5, critMult: 1.5,
primary, primary,
primaryStat: cls.primary,
}; };
} }
@@ -63,44 +65,6 @@ function calcDerived(stats, level, classKey, equipBonuses = {}) {
*/ */
const computeEnergy = _computeEnergy; 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 = { module.exports = {
ENERGY_MAX, ENERGY_MAX,
ENERGY_REGEN_SECONDS, ENERGY_REGEN_SECONDS,
+22 -13
View File
@@ -4,21 +4,29 @@ const config = require('../config');
const { getDb } = require('../db'); const { getDb } = require('../db');
const { AppError } = require('../utils/errors'); const { AppError } = require('../utils/errors');
function createSessionMiddleware() { // better-sqlite3-session-store ignores `expired.clear: false` (its code is `clear || true`), so it always
// Use memory store in tests to avoid session store cleanup interval hanging tests // starts a cleanup setInterval. We unref that timer so it can never keep the process (or a test run) alive.
const useMemoryStore = config.nodeEnv === 'test'; function createSqliteStore() {
const realSetInterval = global.setInterval;
global.setInterval = (...args) => {
const timer = realSetInterval(...args);
if (timer && typeof timer.unref === 'function') timer.unref();
return timer;
};
try {
return new SessionStore({
client: getDb(),
table: 'sessions',
expired: { clear: true, intervalMs: 15 * 60 * 1000 },
});
} finally {
global.setInterval = realSetInterval;
}
}
function createSessionMiddleware() {
return session({ return session({
store: useMemoryStore store: createSqliteStore(), // real persistent store in every environment, including tests
? undefined // express-session built-in memory store
: new SessionStore({
client: getDb(),
table: 'sessions',
expired: {
clear: true,
intervalMs: 900000, // 15 min cleanup
},
}),
secret: config.sessionSecret, secret: config.sessionSecret,
resave: false, resave: false,
saveUninitialized: false, saveUninitialized: false,
@@ -38,6 +46,7 @@ function requireAuth(req, res, next) {
// Return JSON error for API requests // Return JSON error for API requests
if (req.path.startsWith('/api/') || req.xhr) { if (req.path.startsWith('/api/') || req.xhr) {
return res.status(401).json({ return res.status(401).json({
ok: false,
error: { error: {
code: 'UNAUTHENTICATED', code: 'UNAUTHENTICATED',
message: 'Authentication required.', message: 'Authentication required.',
+2 -2
View File
@@ -18,13 +18,13 @@ const loginSchema = zod.strictObject({
const registerLimiter = rateLimit({ const registerLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour windowMs: 60 * 60 * 1000, // 1 hour
max: process.env.NODE_ENV === 'test' ? 1000 : 5, max: process.env.NODE_ENV === 'test' ? 1000 : 5,
message: { error: { code: 'RATE_LIMITED', message: 'Too many registration attempts. Try again later.' } }, message: { ok: false, error: { code: 'RATE_LIMITED', message: 'Too many registration attempts. Try again later.' } },
}); });
const loginLimiter = rateLimit({ const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes windowMs: 15 * 60 * 1000, // 15 minutes
max: process.env.NODE_ENV === 'test' ? 1000 : 10, max: process.env.NODE_ENV === 'test' ? 1000 : 10,
message: { error: { code: 'RATE_LIMITED', message: 'Too many login attempts. Try again later.' } }, message: { ok: false, error: { code: 'RATE_LIMITED', message: 'Too many login attempts. Try again later.' } },
}); });
function wrap(fn) { function wrap(fn) {
+3 -9
View File
@@ -3,6 +3,7 @@ const zod = require('zod');
const { AppError } = require('../utils/errors'); const { AppError } = require('../utils/errors');
const { requireAuth } = require('../middleware/session'); const { requireAuth } = require('../middleware/session');
const { hunt } = require('../services/huntService'); const { hunt } = require('../services/huntService');
const { getCharacterIdForUser } = require('../services/characterService');
// Validation schema // Validation schema
const huntSchema = zod.strictObject({ const huntSchema = zod.strictObject({
@@ -20,16 +21,9 @@ async function handleHunt(req, res) {
} }
const { zoneId } = parsed.data; const { zoneId } = parsed.data;
const userId = req.session.userId;
// Get character ID for this user const characterId = getCharacterIdForUser(req.session.userId);
const db = require('../db').getDb(); const result = hunt(characterId, zoneId);
const character = db.prepare('SELECT id FROM characters WHERE user_id = ?').get(userId);
if (!character) {
throw new AppError('NO_CHARACTER', 'No character found.', 404);
}
const result = hunt(character.id, zoneId);
res.json({ res.json({
ok: true, ok: true,
+3 -2
View File
@@ -1,5 +1,6 @@
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const { getDb } = require('../db'); const { getDb } = require('../db');
const time = require('../utils/time');
const { AppError } = require('../utils/errors'); const { AppError } = require('../utils/errors');
const BCRYPT_COST = 12; const BCRYPT_COST = 12;
@@ -38,7 +39,7 @@ async function register(username, password) {
} }
const passwordHash = await bcrypt.hash(password, BCRYPT_COST); const passwordHash = await bcrypt.hash(password, BCRYPT_COST);
const createdAt = Date.now(); const createdAt = time.now();
const result = s.insertUser.run(username, passwordHash, createdAt); const result = s.insertUser.run(username, passwordHash, createdAt);
const user = { id: result.lastInsertRowid, username }; const user = { id: result.lastInsertRowid, username };
@@ -68,7 +69,7 @@ async function verifyLogin(username, password) {
} }
// Update last_login_at // Update last_login_at
db.prepare('UPDATE users SET last_login_at = ? WHERE id = ?').run(Date.now(), user.id); db.prepare('UPDATE users SET last_login_at = ? WHERE id = ?').run(time.now(), user.id);
const { password_hash: _, ...safeUser } = user; const { password_hash: _, ...safeUser } = user;
return safeUser; return safeUser;
+19 -11
View File
@@ -1,11 +1,11 @@
// Character service: create and retrieve characters with derived stats and energy. // Character service: create and retrieve characters with derived stats and energy.
const { getDb } = require('../db'); const { getDb } = require('../db');
const time = require('../utils/time');
const { AppError } = require('../utils/errors'); const { AppError } = require('../utils/errors');
const { getBaseStats } = require('../game/classes'); const { getBaseStats } = require('../game/classes');
const { calcDerived, computeEnergy, ENERGY_MAX } = require('../game/stats'); const { calcDerived, computeEnergy, ENERGY_MAX, ENERGY_REGEN_SECONDS } = require('../game/stats');
const { computeEnergy: computeEnergyPure } = require('../game/energy'); const { computeEnergy: computeEnergyPure } = require('../game/energy');
const ENERGY_UPDATED_AT_DEFAULT = Date.now();
function wrapStmts(db) { function wrapStmts(db) {
return { return {
@@ -41,7 +41,7 @@ function createCharacter(userId, name, classKey) {
const db = getDb(); const db = getDb();
const stmts = getStmts(); const stmts = getStmts();
const baseStats = getBaseStats(classKey); const baseStats = getBaseStats(classKey);
const now = Date.now(); const now = time.now();
// Check if user already has a character // Check if user already has a character
const existing = stmts.findByUserId.get(userId); const existing = stmts.findByUserId.get(userId);
@@ -55,12 +55,12 @@ function createCharacter(userId, name, classKey) {
throw new AppError('NAME_TAKEN', 'Hero name is already taken.', 409); 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 hpCurrent = calcDerived(baseStats, 1, classKey).maxHp; // start at full HP
const result = stmts.insertCharacter.run( const result = stmts.insertCharacter.run(
userId, name, classKey, userId, name, classKey,
baseStats.str, baseStats.agi, baseStats.int, baseStats.vit, baseStats.str, baseStats.agi, baseStats.int, baseStats.vit,
hpCurrent, ENERGY_MAX, ENERGY_UPDATED_AT_DEFAULT, now hpCurrent, ENERGY_MAX, now, now
); );
return { id: result.lastInsertRowid }; return { id: result.lastInsertRowid };
@@ -85,7 +85,7 @@ function getCharacterProfile(userId) {
); );
// Compute current energy with lazy regen // Compute current energy with lazy regen
const energy = computeEnergy(character.energy, character.energy_updated_at, Date.now()); const energy = computeEnergy(character.energy, character.energy_updated_at, time.now());
// Update energy in DB if it changed // Update energy in DB if it changed
if (energy.energy !== character.energy || energy.updatedAtMs !== character.energy_updated_at) { if (energy.energy !== character.energy || energy.updatedAtMs !== character.energy_updated_at) {
@@ -99,9 +99,10 @@ function getCharacterProfile(userId) {
} }
// Calculate next energy tick // Calculate next energy tick
const now = Date.now(); const now = time.now();
const nextTickMs = character.energy_updated_at + (ENERGY_MAX - character.energy) * 180 * 1000; // Seconds until the NEXT +1 energy (0 when full). Not the time until full.
const nextTickSec = Math.max(0, Math.ceil((nextTickMs - now) / 1000)); const nextTickMs = character.energy_updated_at + ENERGY_REGEN_SECONDS * 1000;
const nextTickSec = character.energy >= ENERGY_MAX ? 0 : Math.max(0, Math.ceil((nextTickMs - now) / 1000));
return { return {
character: { character: {
@@ -146,7 +147,7 @@ function getCharacterProfile(userId) {
*/ */
function spendEnergy(characterId, cost) { function spendEnergy(characterId, cost) {
const db = getDb(); const db = getDb();
const now = Date.now(); const now = time.now();
// Validate cost // Validate cost
if (typeof cost !== 'number' || cost <= 0 || !Number.isInteger(cost)) { if (typeof cost !== 'number' || cost <= 0 || !Number.isInteger(cost)) {
@@ -177,4 +178,11 @@ function spendEnergy(characterId, cost) {
return { energy: energy.energy - cost, updatedAtMs: energy.updatedAtMs }; return { energy: energy.energy - cost, updatedAtMs: energy.updatedAtMs };
} }
module.exports = { createCharacter, getCharacterProfile, spendEnergy }; // Resolve the logged-in user's character id (routes must not run SQL themselves).
function getCharacterIdForUser(userId) {
const row = getStmts().findByUserId.get(userId);
if (!row) throw new AppError('NO_CHARACTER', 'No character found.', 404);
return row.id;
}
module.exports = { createCharacter, getCharacterProfile, spendEnergy, getCharacterIdForUser };
+158 -273
View File
@@ -1,284 +1,169 @@
// Hunt service: resolves a hunting attempt in a zone. // Hunt service: resolves one hunting attempt in a zone.
// Combines energy spending, monster selection, combat, rewards, and logging // Everything (reads, energy spend, combat, rewards, loot, log) happens in ONE transaction.
// all within a single database transaction. const crypto = require('node:crypto');
const { getDb } = require('../db'); const { getDb } = require('../db');
const { AppError } = require('../utils/errors'); const { AppError } = require('../utils/errors');
const { computeEnergy } = require('../game/energy'); const time = require('../utils/time');
const { resolveCombat } = require('../game/combat');
const { calculateRewards, rollLoot, materialSellPrice } = require('../game/rewards');
const { checkLevelUp } = require('../game/leveling');
const { mulberry32 } = require('../utils/rng'); const { mulberry32 } = require('../utils/rng');
const { computeEnergy } = require('../game/energy');
const { calcDerived } = require('../game/stats');
const { resolveCombat } = require('../game/combat');
const { calculateRewards, rollLoot } = require('../game/rewards');
const { checkLevelUp } = require('../game/leveling');
function wrapStmts(db) { const MIN_HP_PCT_TO_HUNT = 0.2;
return { const DEFEAT_GOLD_LOSS_PCT = 0.10;
findCharacterById: db.prepare(
'SELECT * FROM characters WHERE id = ?' // Prepared statements are cached PER database handle, so a new DB (e.g. in tests) never reuses stale ones.
), const stmtCache = new WeakMap();
findZoneById: db.prepare(
'SELECT * FROM zones WHERE id = ?' function getStmts(db) {
), let s = stmtCache.get(db);
selectMonstersByZone: db.prepare( if (!s) {
'SELECT * FROM monsters WHERE zone_id = ?' s = {
), character: db.prepare('SELECT * FROM characters WHERE id = ?'),
selectLootByMonster: db.prepare( zone: db.prepare('SELECT * FROM zones WHERE id = ?'),
'SELECT lt.*, i.slug, i.type FROM loot_table lt JOIN items i ON i.id = lt.item_id WHERE lt.monster_id = ?' monsters: db.prepare('SELECT * FROM monsters WHERE zone_id = ?'),
), loot: db.prepare('SELECT item_id, chance_pct, qty_min, qty_max FROM loot_table WHERE monster_id = ?'),
updateCharacter: db.prepare( item: db.prepare('SELECT id, slug, name, type, rarity, stackable, effect FROM items WHERE id = ?'),
`UPDATE characters SET stackRow: db.prepare(
hp_current = ?, energy = ?, energy_updated_at = ?, 'SELECT id FROM inventory WHERE character_id = ? AND item_id = ? AND equipped_slot IS NULL'
xp = ?, gold = ?, level = ?, unspent_points = ?, kills = ? ),
WHERE id = ?` addToStack: db.prepare('UPDATE inventory SET quantity = quantity + ? WHERE id = ?'),
), insertInv: db.prepare(
insertCombatLog: db.prepare( 'INSERT INTO inventory (character_id, item_id, quantity, acquired_at) VALUES (?, ?, ?, ?)'
`INSERT INTO combat_log (character_id, monster_id, won, rounds, xp_gained, gold_gained, created_at) ),
VALUES (?, ?, ?, ?, ?, ?, ?)` updateChar: db.prepare(
), `UPDATE characters SET hp_current = ?, energy = ?, energy_updated_at = ?, xp = ?, gold = ?,
insertInventoryItem: db.prepare( level = ?, unspent_points = ?, kills = ?, deaths = ? WHERE id = ?`
`INSERT INTO inventory (character_id, item_id, quantity, acquired_at) ),
VALUES (?, ?, ?, ?)` insertLog: db.prepare(
), `INSERT INTO combat_log (character_id, monster_id, won, rounds, xp_gained, gold_gained, created_at)
getInventoryByCharacter: db.prepare( VALUES (?, ?, ?, ?, ?, ?, ?)`
'SELECT i.*, it.slug, it.name, it.type, it.rarity, it.effect FROM inventory i JOIN items it ON it.id = i.item_id WHERE i.character_id = ?' ),
), };
getItemById: db.prepare( stmtCache.set(db, s);
'SELECT id, slug, name, type, rarity, effect FROM items WHERE id = ?' }
), return s;
};
} }
let cachedStmts; function pickWeightedMonster(monsters, rng) {
const total = monsters.reduce((sum, m) => sum + m.weight, 0);
function getStmts() { let r = rng() * total;
if (!cachedStmts) { for (const m of monsters) {
const db = getDb(); r -= m.weight;
cachedStmts = wrapStmts(db); if (r < 0) return m;
}
return cachedStmts;
}
/**
* Hunt in a zone: spend energy, fight a monster, apply rewards/penalty.
* @param {number} characterId - the hero's ID
* @param {number} zoneId - the zone to hunt in
* @returns {object} { winner, rounds, hpRemaining, xpGained, goldGained, loot, leveledUp, zone }
*/
function hunt(characterId, zoneId) {
const db = getDb();
const stmts = getStmts();
const now = Date.now();
// Validate inputs
if (typeof zoneId !== 'number' || zoneId <= 0) {
throw new AppError('VALIDATION_ERROR', 'Invalid zone ID.', 400);
}
// Get character
const character = stmts.findCharacterById.get(characterId);
if (!character) {
throw new AppError('NOT_FOUND', 'Character not found.', 404);
}
// Get zone
const zone = stmts.findZoneById.get(zoneId);
if (!zone) {
throw new AppError('NOT_FOUND', 'Zone not found.', 404);
}
// Check HP threshold (must be >= 20% max HP)
const heroDerived = calcDerivedForCombat(character);
if (character.hp_current < heroDerived.maxHp * 0.2) {
throw new AppError('HP_TOO_LOW', 'Your HP is too low. Rest or use a potion.', 422);
}
// Spend energy
const cost = zone.energy_cost;
const energy = computeEnergy(character.energy, character.energy_updated_at, now);
if (energy.energy < cost) {
throw new AppError('INSUFFICIENT_ENERGY', `Not enough energy. Have ${energy.energy}, need ${cost}.`, 422);
}
// Pick random monster (weighted by spawn weight)
const monsters = stmts.selectMonstersByZone.all(zoneId);
if (monsters.length === 0) {
throw new AppError('ZONE_EMPTY', 'No monsters in this zone.', 500);
}
const monster = pickWeightedMonster(monsters);
// Resolve combat
const seed = now + Math.random() * 1000000;
const rng = mulberry32(seed);
const combatResult = resolveCombat(
{
class: character.class,
level: character.level,
hpCurrent: character.hp_current,
maxHp: heroDerived.maxHp,
str: character.str,
agi: character.agi,
int: character.int,
vit: character.vit,
bonus_attack: 0,
bonus_defense: 0,
bonus_vit: 0,
},
{
name: monster.name,
level: monster.level,
hp: monster.hp,
attack: monster.attack,
defense: monster.defense,
speed: monster.speed,
},
rng
);
// Apply results inside a transaction
let txResult;
try {
const tx = db.transaction(() => {
let newHp = combatResult.hpRemaining;
let newXp = character.xp;
let newGold = character.gold;
let newLevel = character.level;
let newUnspent = character.unspent_points;
let newKills = character.kills;
let xpGained = 0;
let goldGained = 0;
let lootDrops = [];
let leveledUp = false;
if (combatResult.winner === 'hero') {
// Rewards
const rewards = calculateRewards(monster.xp, monster.gold_min, monster.gold_max, monster.level, character.level, rng);
xpGained = rewards.xp;
goldGained = rewards.gold;
newXp += xpGained;
newGold += goldGained;
// Loot rolls
const lootCandidates = stmts.selectLootByMonster.all(monster.id);
const lootRolls = rollLoot(lootCandidates.map(lc => ({
itemId: lc.item_id,
chancePct: lc.chance_pct,
qtyMin: lc.qty_min,
qtyMax: lc.qty_max,
})), rng);
// Insert loot into inventory and enrich with item details
for (const drop of lootRolls) {
stmts.insertInventoryItem.run(characterId, drop.itemId, drop.quantity, now);
const item = stmts.getItemById.get(drop.itemId);
if (item) {
lootDrops.push({
slug: item.slug,
name: item.name,
type: item.type,
rarity: item.rarity,
quantity: drop.quantity,
effect: item.effect,
});
}
}
// Check level-up
const levelResult = checkLevelUp(character.level, character.xp, xpGained);
if (levelResult.leveledUp) {
leveledUp = true;
newLevel = levelResult.newLevel;
newUnspent = levelResult.unspentPoints;
newHp = heroDerived.maxHp; // Full HP on level-up
}
newKills += 1;
} else {
// Defeat penalty: lose 10% gold (min 0), HP set to 1, deaths +1
const goldLost = Math.floor(character.gold * 0.10);
newGold = Math.max(0, character.gold - goldLost);
newHp = 1;
// deaths incremented in DB update
}
// Update character
stmts.updateCharacter.run(
newHp,
energy.energy - cost,
energy.updatedAtMs,
newXp,
newGold,
newLevel,
newUnspent,
newKills,
characterId
);
// Combat log
stmts.insertCombatLog.run(
characterId,
monster.id,
combatResult.winner === 'hero' ? 1 : 0,
combatResult.rounds.length,
xpGained,
goldGained,
now
);
txResult = {
xpGained,
goldGained,
loot: lootDrops,
leveledUp,
goldLost: combatResult.winner === 'monster' ? Math.floor(character.gold * 0.10) : 0,
};
});
tx();
} catch (err) {
throw err;
}
// Build response
const response = {
winner: combatResult.winner,
rounds: combatResult.rounds,
hpRemaining: combatResult.hpRemaining,
hpMax: heroDerived.maxHp,
xpGained: txResult.xpGained,
goldGained: txResult.goldGained,
goldLost: txResult.goldLost,
loot: txResult.loot,
leveledUp: txResult.leveledUp,
zone: { id: zone.id, slug: zone.slug, name: zone.name },
monster: { id: monster.id, name: monster.name, level: monster.level },
};
return response;
}
function calcDerivedForCombat(character) {
const cls = require('../game/classes').getClass(character.class);
const primary = cls.primary === 'str' ? character.str
: cls.primary === 'agi' ? character.agi
: character.int;
const maxHp = 50 + character.vit * 10 + character.level * 5;
const attack = 5 + primary * 2 + character.level;
let defense = Math.floor(character.vit * 1.5);
if (cls.bonuses.defensePct) {
defense = Math.floor(defense * (1 + cls.bonuses.defensePct));
}
const speed = 10 + character.agi;
let critChance = Math.min(40, 5 + Math.floor(character.agi * 0.5));
if (cls.bonuses.critChance) {
critChance = Math.min(40, critChance + Math.floor(cls.bonuses.critChance * 100));
}
return { maxHp, attack, defense, speed, critChance };
}
function pickWeightedMonster(monsters) {
const totalWeight = monsters.reduce((sum, m) => sum + m.weight, 0);
let r = Math.random() * totalWeight;
for (const monster of monsters) {
r -= monster.weight;
if (r <= 0) return monster;
} }
return monsters[monsters.length - 1]; return monsters[monsters.length - 1];
} }
// Stackable items merge into one row per (character, item); others get one row per unit.
function grantItem(s, characterId, item, quantity, now) {
if (item.stackable) {
const row = s.stackRow.get(characterId, item.id);
if (row) s.addToStack.run(quantity, row.id);
else s.insertInv.run(characterId, item.id, quantity, now);
} else {
for (let i = 0; i < quantity; i++) s.insertInv.run(characterId, item.id, 1, now);
}
}
/**
* @param {number} characterId
* @param {number} zoneId
* @param {{rng?: function, now?: number}} [opts] - injectable for tests
*/
function hunt(characterId, zoneId, opts = {}) {
if (!Number.isInteger(zoneId) || zoneId <= 0) {
throw new AppError('VALIDATION_ERROR', 'Invalid zone ID.', 400);
}
const db = getDb();
const s = getStmts(db);
const now = opts.now ?? time.now();
const rng = opts.rng || mulberry32(crypto.randomInt(0, 2 ** 31));
return db.transaction(() => {
const ch = s.character.get(characterId);
if (!ch) throw new AppError('NOT_FOUND', 'Character not found.', 404);
const zone = s.zone.get(zoneId);
if (!zone) throw new AppError('NOT_FOUND', 'Zone not found.', 404);
if (ch.level < zone.min_level) {
throw new AppError('LEVEL_TOO_LOW', `${zone.name} requires level ${zone.min_level}.`, 422);
}
const baseStats = { str: ch.str, agi: ch.agi, int: ch.int, vit: ch.vit };
const derived = calcDerived(baseStats, ch.level, ch.class);
if (ch.hp_current < derived.maxHp * MIN_HP_PCT_TO_HUNT) {
throw new AppError('HP_TOO_LOW', 'Your HP is too low. Rest or use a potion.', 422);
}
const energy = computeEnergy(ch.energy, ch.energy_updated_at, now);
if (energy.energy < zone.energy_cost) {
throw new AppError(
'INSUFFICIENT_ENERGY',
`Not enough energy. Have ${energy.energy}, need ${zone.energy_cost}.`, 422
);
}
const monsters = s.monsters.all(zoneId);
if (monsters.length === 0) throw new AppError('ZONE_EMPTY', 'No monsters in this zone.', 500);
const monster = pickWeightedMonster(monsters, rng);
const fight = resolveCombat({ ...baseStats, class: ch.class, level: ch.level, hpCurrent: ch.hp_current }, monster, rng);
let { xp, gold, level, unspent_points: unspent, kills, deaths } = ch;
let hp = fight.hpRemaining;
let xpGained = 0, goldGained = 0, goldLost = 0, leveledUp = false;
const loot = [];
if (fight.winner === 'hero') {
const rewards = calculateRewards(monster.xp, monster.gold_min, monster.gold_max, monster.level, ch.level, rng);
xpGained = rewards.xp;
goldGained = rewards.gold;
gold += goldGained;
kills += 1;
for (const drop of rollLoot(s.loot.all(monster.id).map((l) => ({
itemId: l.item_id, chancePct: l.chance_pct, qtyMin: l.qty_min, qtyMax: l.qty_max,
})), rng)) {
const item = s.item.get(drop.itemId);
if (!item) continue;
grantItem(s, characterId, item, drop.quantity, now);
loot.push({ slug: item.slug, name: item.name, type: item.type, rarity: item.rarity, quantity: drop.quantity });
}
// `xp` is progress WITHIN the current level; checkLevelUp returns the leftover.
const lv = checkLevelUp(ch.level, ch.xp, xpGained);
xp = lv.totalXp;
if (lv.leveledUp) {
leveledUp = true;
level = lv.newLevel;
unspent += lv.unspentPoints;
hp = calcDerived(baseStats, level, ch.class).maxHp; // full heal at the NEW max HP
}
} else {
goldLost = Math.floor(ch.gold * DEFEAT_GOLD_LOSS_PCT);
gold = Math.max(0, ch.gold - goldLost);
hp = 1;
deaths += 1;
}
s.updateChar.run(hp, energy.energy - zone.energy_cost, energy.updatedAtMs, xp, gold, level, unspent, kills, deaths, characterId);
s.insertLog.run(characterId, monster.id, fight.winner === 'hero' ? 1 : 0, fight.rounds.length, xpGained, goldGained, now);
return {
winner: fight.winner,
rounds: fight.rounds,
hpRemaining: hp,
hpMax: calcDerived(baseStats, level, ch.class).maxHp,
xpGained, goldGained, goldLost, loot, leveledUp,
newLevel: level,
energy: energy.energy - zone.energy_cost,
zone: { id: zone.id, slug: zone.slug, name: zone.name },
monster: { id: monster.id, name: monster.name, level: monster.level },
};
})();
}
module.exports = { hunt }; module.exports = { hunt };
+12
View File
@@ -0,0 +1,12 @@
// Single source of "now" (ms since epoch, UTC). Services call now() instead of Date.now()
// so tests can control the clock with setNow().
let override = null;
function now() {
return override ? override() : Date.now();
}
function setNow(fn) { override = fn; }
function resetNow() { override = null; }
module.exports = { now, setNow, resetNow };
-118
View File
@@ -1,118 +0,0 @@
// Tests for src/game/combat.js — pure combat resolution.
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const { resolveCombat } = require('../src/game/combat');
const { mulberry32 } = require('../src/utils/rng');
describe('resolveCombat', () => {
const heroBase = {
class: 'warrior', level: 1, hpCurrent: 70, maxHp: 70,
str: 8, agi: 4, int: 2, vit: 8,
bonus_attack: 0, bonus_defense: 0, bonus_vit: 0,
};
const rat = { name: 'Giant Rat', level: 1, hp: 20, attack: 6, defense: 1, speed: 12 };
const wolf = { name: 'Meadow Wolf', level: 2, hp: 32, attack: 9, defense: 3, speed: 16 };
const goblin = { name: 'Goblin Scrapper', level: 3, hp: 45, attack: 12, defense: 5, speed: 11 };
const drake = { name: 'Ashfang Drake', level: 14, hp: 300, attack: 50, defense: 30, speed: 18 };
it('hero wins against equal-level monster', () => {
const rng = mulberry32(42);
const result = resolveCombat({ ...heroBase }, rat, rng);
assert.strictEqual(result.winner, 'hero');
assert.ok(result.rounds.length > 0);
assert.ok(result.hpRemaining > 0);
});
it('monster wins against much higher-level monster', () => {
const rng = mulberry32(99);
const result = resolveCombat({ ...heroBase }, drake, rng);
assert.strictEqual(result.winner, 'monster');
assert.ok(result.rounds.length > 0);
assert.ok(result.hpRemaining > 0);
});
it('returns round-by-round log', () => {
const rng = mulberry32(42);
const result = resolveCombat({ ...heroBase }, rat, rng);
for (const round of result.rounds) {
assert.ok('round' in round);
assert.ok('actor' in round);
assert.ok('damage' in round);
assert.ok('targetHpAfter' in round);
assert.ok(round.damage >= 1);
}
});
it('hero acts first when speed is equal or higher', () => {
// Hero warrior speed = 10 + 4 = 14. Rat speed = 12. Hero acts first.
const rng = mulberry32(1);
const result = resolveCombat({ ...heroBase }, rat, rng);
assert.strictEqual(result.rounds[0].actor, 'hero');
});
it('monster acts first when monster speed is higher', () => {
// Wolf speed = 16. Hero warrior speed = 14. Monster acts first.
const rng = mulberry32(1);
const result = resolveCombat({ ...heroBase }, wolf, rng);
assert.strictEqual(result.rounds[0].actor, 'monster');
});
it('deterministic with same seed', () => {
const rng1 = mulberry32(123);
const result1 = resolveCombat({ ...heroBase }, goblin, rng1);
const rng2 = mulberry32(123);
const result2 = resolveCombat({ ...heroBase }, goblin, rng2);
assert.deepStrictEqual(result1.winner, result2.winner);
assert.deepStrictEqual(result1.rounds.length, result2.rounds.length);
assert.deepStrictEqual(result1.hpRemaining, result2.hpRemaining);
});
it('caps at 20 rounds — higher HP% wins', () => {
// Create a scenario where both are nearly dead after many rounds
// Use a seed that produces low damage rolls
const rng = mulberry32(7777);
const weakHero = { ...heroBase, hpCurrent: 10, maxHp: 70, str: 2, agi: 2, vit: 2 };
const weakMonster = { ...rat, hp: 10, attack: 1, defense: 0, speed: 5 };
const result = resolveCombat(weakHero, weakMonster, rng);
assert.ok(result.winner === 'hero' || result.winner === 'monster');
assert.ok(result.rounds.length <= 20);
});
it('mage gets bonus vs high-defense monster', () => {
const mageHero = {
class: 'mage', level: 5, hpCurrent: 55, maxHp: 55,
str: 2, agi: 4, int: 9, vit: 5,
bonus_attack: 0, bonus_defense: 0, bonus_vit: 0,
};
// Stone Golem has defense 40
const golem = { name: 'Stone Golem', level: 12, hp: 260, attack: 38, defense: 40, speed: 6 };
const rng = mulberry32(55);
const result = resolveCombat(mageHero, golem, rng);
// Just verify it runs without error and returns valid structure
assert.ok(result.winner === 'hero' || result.winner === 'monster');
assert.ok(result.rounds.length > 0);
});
it('crit hits deal multiplied damage', () => {
// Force a crit by using a seed that produces rng() * 100 < critChance on first roll
// Warrior critChance = min(40, 5 + floor(4*0.5)) = 7%
// We can't easily force a crit, but verify crit field exists in log
const rng = mulberry32(42);
const result = resolveCombat({ ...heroBase }, rat, rng);
const critRounds = result.rounds.filter(r => r.crit);
// Crit may or may not occur, but if it does, damage should be higher
for (const cr of critRounds) {
assert.strictEqual(cr.crit, true);
}
});
it('monster crit field is present', () => {
const rng = mulberry32(1);
const result = resolveCombat({ ...heroBase }, wolf, rng);
const monsterRounds = result.rounds.filter(r => r.actor === 'monster');
for (const mr of monsterRounds) {
assert.ok('crit' in mr);
}
});
});
+7 -11
View File
@@ -10,17 +10,13 @@ let dbRef;
function createFreshApp() { function createFreshApp() {
process.env.DB_PATH = dbPath; process.env.DB_PATH = dbPath;
const cached = [ // Drop EVERY app module from the require cache so each test gets a fresh app + DB handle.
require.resolve('../src/db'), // (A hand-picked list missed routes/*, which kept services bound to a previous, closed DB.)
require.resolve('../src/config'), const root = path.resolve(__dirname, '..');
require.resolve('../src/middleware/session'), for (const id of Object.keys(require.cache)) {
require.resolve('../src/services/authService'), if (id.startsWith(path.join(root, 'src') + path.sep) || id.startsWith(path.join(root, 'scripts') + path.sep)) {
require.resolve('../src/services/huntService'), delete require.cache[id];
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'); const { runMigrations } = require('../scripts/migrate');
@@ -1,7 +1,7 @@
const test = require('node:test'); const test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const http = require('node:http'); const http = require('node:http');
const { setup, teardown, cleanup } = require('./helpers'); const { setup, teardown, cleanup } = require('../helpers');
// Simple cookie jar for tests // Simple cookie jar for tests
function createJar() { function createJar() {
@@ -0,0 +1,54 @@
// Service-level tests for characterService (profile + energy countdown).
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { setup, teardown, cleanup } = require('../helpers');
const NOW = 1_700_000_000_000;
describe('characterService.getCharacterProfile', () => {
let db, svc, time;
before(async () => {
await setup();
db = require('../../src/db').getDb();
svc = require('../../src/services/characterService');
time = require('../../src/utils/time');
time.setNow(() => NOW);
});
after(async () => { time.resetNow(); await teardown(); cleanup(); });
function hero(n, energy, updatedAt) {
const uid = Number(db.prepare('INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, ?)').run(`cu${n}`, 'x', NOW).lastInsertRowid);
const { id } = svc.createCharacter(uid, `CHero${n}`, 'mage');
db.prepare('UPDATE characters SET energy = ?, energy_updated_at = ? WHERE id = ?').run(energy, updatedAt, id);
return uid;
}
it('REGRESSION: nextTickInSec is time to the NEXT +1, not time until full', () => {
// 40/100 energy, last tick 60s ago → next +1 in 120s (regen is 180s per point)
const p = svc.getCharacterProfile(hero(1, 40, NOW - 60_000));
assert.equal(p.energy.current, 40);
assert.equal(p.energy.nextTickInSec, 120);
});
it('nextTickInSec is 0 when energy is full', () => {
const p = svc.getCharacterProfile(hero(2, 100, NOW));
assert.equal(p.energy.current, 100);
assert.equal(p.energy.nextTickInSec, 0);
});
it('applies offline regen when the profile is read', () => {
// 10 energy, last tick 30 minutes ago → +10
const p = svc.getCharacterProfile(hero(3, 10, NOW - 30 * 60_000));
assert.equal(p.energy.current, 20);
});
it('a new hero starts at full HP and full energy, timestamped "now" (not server start time)', () => {
const uid = Number(db.prepare('INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, ?)').run('cu4', 'x', NOW).lastInsertRowid);
const { id } = svc.createCharacter(uid, 'CHero4', 'warrior');
const c = db.prepare('SELECT * FROM characters WHERE id = ?').get(id);
assert.equal(c.energy, 100);
assert.equal(c.energy_updated_at, NOW);
assert.equal(c.hp_current, 135); // warrior L1: 50 + 8*10 + 5
});
});
@@ -1,7 +1,7 @@
const test = require('node:test'); const test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const http = require('node:http'); const http = require('node:http');
const { createApp } = require('../src/app'); const { createApp } = require('../../src/app');
function startServer(app) { function startServer(app) {
return new Promise((resolve) => { return new Promise((resolve) => {
+133
View File
@@ -0,0 +1,133 @@
// Service-level tests for huntService.hunt(): outcomes are forced via test-only zones/monsters,
// so results are deterministic and we can assert exactly what was persisted.
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { setup, teardown, cleanup } = require('../helpers');
const NOW = 1_700_000_000_000;
const rng = () => 0.5; // constant: loot chance 100% always drops, no crits for warriors (50 > 7)
describe('huntService.hunt', () => {
let db, hunt, createCharacter, userSeq = 0;
let stackableId, weaponId;
before(async () => {
await setup(); // fresh DB + migrations (includes seeded content)
db = require('../../src/db').getDb();
({ hunt } = require('../../src/services/huntService'));
({ createCharacter } = require('../../src/services/characterService'));
stackableId = db.prepare('SELECT id FROM items WHERE stackable = 1 LIMIT 1').get().id;
weaponId = db.prepare("SELECT id FROM items WHERE type = 'weapon' AND stackable = 0 LIMIT 1").get().id;
// Test-only zones. 100: trivial monster (always wins). 101: unkillable monster (always loses). 102: level-5 gate.
db.exec(`
INSERT INTO zones (id, slug, name, description, min_level, energy_cost) VALUES
(100, 't-easy', 'Test Easy', 'x', 1, 5), (101, 't-deadly', 'Test Deadly', 'x', 1, 5), (102, 't-gated', 'Test Gated', 'x', 5, 5);
INSERT INTO monsters (id, zone_id, name, level, hp, attack, defense, speed, xp, gold_min, gold_max, weight) VALUES
(100, 100, 'Test Dummy', 1, 1, 1, 0, 1, 150, 10, 10, 10),
(101, 101, 'Test Titan', 1, 99999, 9999, 0, 1, 50, 5, 5, 10),
(102, 102, 'Test Gated Mob', 5, 1, 1, 0, 1, 50, 5, 5, 10);
INSERT INTO loot_table (monster_id, item_id, chance_pct, qty_min, qty_max) VALUES
(100, ${stackableId}, 100, 1, 1), (100, ${weaponId}, 100, 1, 1);
`);
});
after(async () => { await teardown(); cleanup(); });
function makeHero(overrides = {}) {
userSeq += 1;
const uid = db.prepare('INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, ?)')
.run(`u${userSeq}`, 'x', NOW).lastInsertRowid;
const { id } = createCharacter(Number(uid), `Hero${userSeq}`, 'warrior');
const cols = Object.keys(overrides);
if (cols.length) {
db.prepare(`UPDATE characters SET ${cols.map((c) => `${c} = ?`).join(', ')} WHERE id = ?`)
.run(...Object.values(overrides), id);
}
return id;
}
const row = (id) => db.prepare('SELECT * FROM characters WHERE id = ?').get(id);
const run = (id, zone, extra = {}) => hunt(id, zone, { rng, now: NOW, ...extra });
const throwsCode = (fn, code) => assert.throws(fn, (e) => e.code === code);
it('a win spends energy and persists xp, gold, kills and a combat_log row', () => {
const id = makeHero({ energy: 100, energy_updated_at: NOW });
const res = run(id, 100);
assert.equal(res.winner, 'hero');
const c = row(id);
assert.equal(c.energy, 95);
assert.equal(c.gold, 50 + 10); // starting gold + monster gold
assert.equal(c.kills, 1);
assert.equal(c.deaths, 0);
assert.equal(db.prepare('SELECT COUNT(*) n FROM combat_log WHERE character_id = ?').get(id).n, 1);
});
it('REGRESSION: level-up deducts the XP spent and ADDS stat points', () => {
const id = makeHero({ energy: 100, energy_updated_at: NOW, unspent_points: 2 });
const res = run(id, 100); // +150 xp at level 1; level 1→2 costs 100
assert.equal(res.leveledUp, true);
const c = row(id);
assert.equal(c.level, 2);
assert.equal(c.xp, 50); // 150 - 100, not 150
assert.equal(c.unspent_points, 5); // 2 existing + 3 new, not overwritten
assert.equal(c.hp_current, res.hpMax); // healed to the NEW max HP
});
it('a defeat costs 10% gold, sets HP to 1, increments deaths, grants no xp, still spends energy', () => {
const id = makeHero({ energy: 100, energy_updated_at: NOW, gold: 200 });
const res = run(id, 101);
assert.equal(res.winner, 'monster');
const c = row(id);
assert.equal(c.gold, 180);
assert.equal(c.hp_current, 1);
assert.equal(c.deaths, 1);
assert.equal(c.kills, 0);
assert.equal(c.xp, 0);
assert.equal(c.energy, 95);
});
it('REGRESSION: zone min_level is enforced and nothing changes on rejection', () => {
const id = makeHero({ energy: 100, energy_updated_at: NOW });
throwsCode(() => run(id, 102), 'LEVEL_TOO_LOW');
assert.equal(row(id).energy, 100);
assert.equal(db.prepare('SELECT COUNT(*) n FROM combat_log WHERE character_id = ?').get(id).n, 0);
});
it('rejects when energy is insufficient, leaving state untouched', () => {
const id = makeHero({ energy: 3, energy_updated_at: NOW });
throwsCode(() => run(id, 100), 'INSUFFICIENT_ENERGY');
assert.equal(row(id).energy, 3);
});
it('regenerates offline energy before checking cost (lazy time)', () => {
// 0 energy, last updated 30 minutes ago → +10 energy (1 per 3 min), cost 5 → 5 left
const id = makeHero({ energy: 0, energy_updated_at: NOW - 30 * 60 * 1000 });
run(id, 100);
assert.equal(row(id).energy, 5);
});
it('rejects hunting with HP below 20% of max', () => {
const id = makeHero({ energy: 100, energy_updated_at: NOW, hp_current: 5 });
throwsCode(() => run(id, 100), 'HP_TOO_LOW');
});
it('rejects unknown zones and invalid ids', () => {
const id = makeHero({ energy: 100, energy_updated_at: NOW });
throwsCode(() => run(id, 99999), 'NOT_FOUND');
throwsCode(() => run(id, -1), 'VALIDATION_ERROR');
throwsCode(() => run(id, 1.5), 'VALIDATION_ERROR');
});
it('REGRESSION: stackable loot merges into one row; non-stackable gets a row per drop', () => {
const id = makeHero({ energy: 100, energy_updated_at: NOW });
run(id, 100);
run(id, 100);
const stack = db.prepare('SELECT quantity FROM inventory WHERE character_id = ? AND item_id = ?').all(id, stackableId);
assert.equal(stack.length, 1);
assert.equal(stack[0].quantity, 2);
const weapons = db.prepare('SELECT quantity FROM inventory WHERE character_id = ? AND item_id = ?').all(id, weaponId);
assert.equal(weapons.length, 2);
assert.ok(weapons.every((w) => w.quantity === 1));
});
});
+105
View File
@@ -0,0 +1,105 @@
// Tests for src/game/combat.js — pure combat resolution (no DB, no clock).
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const { resolveCombat, MAX_ROUNDS } = require('../../src/game/combat');
const { mulberry32 } = require('../../src/utils/rng');
// Level-1 warrior: attack 22, defense 13, speed 14, maxHp 135, crit 7%
const warrior = {
class: 'warrior', level: 1, hpCurrent: 135, str: 8, agi: 4, int: 2, vit: 8,
};
const rat = { name: 'Giant Rat', level: 1, hp: 20, attack: 6, defense: 1, speed: 12 };
const wolf = { name: 'Meadow Wolf', level: 2, hp: 32, attack: 9, defense: 3, speed: 16 };
const drake = { name: 'Ashfang Drake', level: 14, hp: 300, attack: 50, defense: 30, speed: 18 };
// A punching bag: very high HP, harmless, slow. Hero always acts first and the fight lasts all rounds.
const dummy = { name: 'Dummy', level: 1, hp: 100000, attack: 1, defense: 0, speed: 1 };
const constRng = (v) => () => v;
describe('resolveCombat', () => {
it('hero beats an easy monster and ends with HP left', () => {
const r = resolveCombat(warrior, rat, mulberry32(42));
assert.equal(r.winner, 'hero');
assert.ok(r.hpRemaining > 0 && r.hpRemaining <= warrior.hpCurrent);
});
it('hero loses to a far stronger monster and hpRemaining is 0 (hero HP, not monster HP)', () => {
const r = resolveCombat(warrior, drake, mulberry32(99));
assert.equal(r.winner, 'monster');
assert.equal(r.hpRemaining, 0);
});
it('REGRESSION: a slower hero still attacks (both sides act every round)', () => {
// Warrior speed 14 < wolf speed 16. The old code never let the slower side act.
const r = resolveCombat(warrior, wolf, mulberry32(1));
const actors = new Set(r.rounds.map((x) => x.actor));
assert.ok(actors.has('hero') && actors.has('monster'));
});
it('the faster side acts first each round; hero wins speed ties', () => {
const vsFast = resolveCombat(warrior, wolf, mulberry32(1));
assert.equal(vsFast.rounds[0].actor, 'monster'); // 16 > 14
assert.equal(vsFast.rounds[1].actor, 'hero');
const vsSlow = resolveCombat(warrior, rat, mulberry32(1));
assert.equal(vsSlow.rounds[0].actor, 'hero'); // 14 > 12
const tie = resolveCombat(warrior, { ...rat, speed: 14 }, mulberry32(1));
assert.equal(tie.rounds[0].actor, 'hero');
});
it('a monster killed by the first attacker never gets to swing', () => {
const glassRat = { ...rat, hp: 1 };
const r = resolveCombat(warrior, glassRat, mulberry32(3));
assert.equal(r.rounds.length, 1);
assert.equal(r.hpRemaining, warrior.hpCurrent);
});
it('is fully deterministic for the same seed', () => {
const a = resolveCombat(warrior, wolf, mulberry32(123));
const b = resolveCombat(warrior, wolf, mulberry32(123));
assert.deepEqual(a, b);
});
it('stops after MAX_ROUNDS and the higher HP% wins', () => {
// Hero deals ~2/hit against a huge-defense, huge-HP monster; monster deals 1/hit.
const tank = { name: 'Wall', level: 1, hp: 1000, attack: 1, defense: 1000, speed: 1 };
const r = resolveCombat({ ...warrior, hpCurrent: 100 }, tank, mulberry32(5));
assert.equal(r.rounds.length, MAX_ROUNDS * 2);
// hero ≈ 80/135 HP left (59%), monster ≈ 960/1000 (96%) → monster wins on percentage
assert.equal(r.winner, 'monster');
assert.ok(r.hpRemaining > 0);
});
it('crits multiply damage by 1.5 (forced with a constant rng)', () => {
// rng=0 → variance 0.9, crit roll 0 < 7% → crit. damage = round(22 * 0.9 * 1.5) = 30
const crit = resolveCombat(warrior, dummy, constRng(0)).rounds[0];
assert.equal(crit.crit, true);
assert.equal(crit.damage, 30);
// rng=0.999 → variance ≈1.0998, no crit. damage = round(22 * 1.0998) = 24
const plain = resolveCombat(warrior, dummy, constRng(0.999)).rounds[0];
assert.equal(plain.crit, false);
assert.equal(plain.damage, 24);
});
it('damage is never below 1', () => {
const wall = { ...dummy, defense: 1000000 };
const r = resolveCombat(warrior, wall, constRng(0.5));
assert.ok(r.rounds.every((x) => x.damage >= 1));
});
it('equipment attack bonus increases damage', () => {
const plain = resolveCombat(warrior, dummy, constRng(0.5)).rounds[0].damage;
const armed = resolveCombat({ ...warrior, bonus_attack: 40 }, dummy, constRng(0.5)).rounds[0].damage;
assert.ok(armed > plain);
});
it('REGRESSION: mage gets +15% only against defense >= 15', () => {
const mage = { class: 'mage', level: 1, hpCurrent: 100000, str: 2, agi: 4, int: 30, vit: 5 };
const dmgAt = (defense) => resolveCombat(mage, { ...dummy, defense }, constRng(0.5)).rounds[0].damage;
// def 15 is MORE mitigation than def 14, yet the mage bonus makes it hit harder.
assert.ok(dmgAt(15) > dmgAt(14));
// A warrior has no such bonus: higher defense means less damage.
const war = { ...warrior, hpCurrent: 100000, str: 30 };
const wDmgAt = (defense) => resolveCombat(war, { ...dummy, defense }, constRng(0.5)).rounds[0].damage;
assert.ok(wDmgAt(15) < wDmgAt(14));
});
});