loop: baseline
This commit is contained in:
@@ -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
@@ -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).
|
||||||
@@ -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).
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
Generated
+1
-1415
File diff suppressed because it is too large
Load Diff
+1
-2
@@ -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
@@ -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) => {
|
||||||
|
|||||||
Executable
+141
@@ -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
|
||||||
@@ -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.',
|
||||||
|
|||||||
+58
-85
@@ -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;
|
|
||||||
rounds.push({ round, actor: 'hero', damage: result.damage, crit: result.crit, targetHpAfter: Math.max(0, monsterHp) });
|
|
||||||
if (monsterHp <= 0) {
|
|
||||||
return { winner: 'hero', rounds, hpRemaining: heroHp };
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
heroHp -= result.damage;
|
heroHp -= damage;
|
||||||
rounds.push({ round, actor: 'monster', damage: result.damage, crit: result.crit, targetHpAfter: Math.max(0, heroHp) });
|
rounds.push({ round, actor: 'monster', damage, crit, targetHpAfter: Math.max(0, heroHp) });
|
||||||
if (heroHp <= 0) {
|
if (heroHp <= 0) return { winner: 'monster', rounds, hpRemaining: 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
@@ -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
@@ -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;
|
||||||
return session({
|
global.setInterval = (...args) => {
|
||||||
store: useMemoryStore
|
const timer = realSetInterval(...args);
|
||||||
? undefined // express-session built-in memory store
|
if (timer && typeof timer.unref === 'function') timer.unref();
|
||||||
: new SessionStore({
|
return timer;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
return new SessionStore({
|
||||||
client: getDb(),
|
client: getDb(),
|
||||||
table: 'sessions',
|
table: 'sessions',
|
||||||
expired: {
|
expired: { clear: true, intervalMs: 15 * 60 * 1000 },
|
||||||
clear: true,
|
});
|
||||||
intervalMs: 900000, // 15 min cleanup
|
} finally {
|
||||||
},
|
global.setInterval = realSetInterval;
|
||||||
}),
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSessionMiddleware() {
|
||||||
|
return session({
|
||||||
|
store: createSqliteStore(), // real persistent store in every environment, including tests
|
||||||
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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
+151
-266
@@ -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();
|
||||||
|
|
||||||
|
function getStmts(db) {
|
||||||
|
let s = stmtCache.get(db);
|
||||||
|
if (!s) {
|
||||||
|
s = {
|
||||||
|
character: db.prepare('SELECT * FROM characters WHERE id = ?'),
|
||||||
|
zone: db.prepare('SELECT * FROM zones WHERE 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 = ?'),
|
||||||
|
item: db.prepare('SELECT id, slug, name, type, rarity, stackable, effect FROM items WHERE id = ?'),
|
||||||
|
stackRow: db.prepare(
|
||||||
|
'SELECT id FROM inventory WHERE character_id = ? AND item_id = ? AND equipped_slot IS NULL'
|
||||||
),
|
),
|
||||||
findZoneById: db.prepare(
|
addToStack: db.prepare('UPDATE inventory SET quantity = quantity + ? WHERE id = ?'),
|
||||||
'SELECT * FROM zones WHERE id = ?'
|
insertInv: db.prepare(
|
||||||
|
'INSERT INTO inventory (character_id, item_id, quantity, acquired_at) VALUES (?, ?, ?, ?)'
|
||||||
),
|
),
|
||||||
selectMonstersByZone: db.prepare(
|
updateChar: db.prepare(
|
||||||
'SELECT * FROM monsters WHERE zone_id = ?'
|
`UPDATE characters SET hp_current = ?, energy = ?, energy_updated_at = ?, xp = ?, gold = ?,
|
||||||
|
level = ?, unspent_points = ?, kills = ?, deaths = ? WHERE id = ?`
|
||||||
),
|
),
|
||||||
selectLootByMonster: db.prepare(
|
insertLog: db.prepare(
|
||||||
'SELECT lt.*, i.slug, i.type FROM loot_table lt JOIN items i ON i.id = lt.item_id WHERE lt.monster_id = ?'
|
|
||||||
),
|
|
||||||
updateCharacter: db.prepare(
|
|
||||||
`UPDATE characters SET
|
|
||||||
hp_current = ?, energy = ?, energy_updated_at = ?,
|
|
||||||
xp = ?, gold = ?, level = ?, unspent_points = ?, kills = ?
|
|
||||||
WHERE id = ?`
|
|
||||||
),
|
|
||||||
insertCombatLog: db.prepare(
|
|
||||||
`INSERT INTO combat_log (character_id, monster_id, won, rounds, xp_gained, gold_gained, created_at)
|
`INSERT INTO combat_log (character_id, monster_id, won, rounds, xp_gained, gold_gained, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||||
),
|
),
|
||||||
insertInventoryItem: db.prepare(
|
|
||||||
`INSERT INTO inventory (character_id, item_id, quantity, acquired_at)
|
|
||||||
VALUES (?, ?, ?, ?)`
|
|
||||||
),
|
|
||||||
getInventoryByCharacter: db.prepare(
|
|
||||||
'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(
|
|
||||||
'SELECT id, slug, name, type, rarity, effect FROM items WHERE id = ?'
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
|
stmtCache.set(db, s);
|
||||||
|
}
|
||||||
|
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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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
@@ -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) => {
|
||||||
@@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user