diff --git a/.gitignore b/.gitignore index 107c040..ed208e7 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ data/*.db-journal data/*.db-wal data/*.db-shm *.log + +# agent loop +.loop/ diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..79e3a59 --- /dev/null +++ b/HANDOFF.md @@ -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). diff --git a/NOTES.md b/NOTES.md index 9c4c953..db55595 100644 --- a/NOTES.md +++ b/NOTES.md @@ -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. 9. **Never store plaintext passwords.** Never log passwords or session secrets. 10. **Don't touch what isn't in the current step.** No drive-by refactors. -11. **Re-read NOTES.md and STATUS.md after each phase.** Keep your context fresh. ## 3. How to work (summary — details in docs/WORKFLOW.md) 1. Open `STATUS.md`. Find the first unchecked step in the lowest-numbered phase. @@ -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 shop → start and claim an expedition → see the leaderboard → log out → log back 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). diff --git a/PROMPT.md b/PROMPT.md index 088ae48..dd34986 100644 --- a/PROMPT.md +++ b/PROMPT.md @@ -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. 3. Implement in small pieces. Run the code/tests after each piece. 4. When a step's acceptance criteria are verified, tick it in `STATUS.md`, add a Session Log line, and note any Known Issues. -5. Continue to the next step. Stop only when blocked or when Phase 9 is complete. +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 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." - 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. +- 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." diff --git a/PROMPT_ONE_STEP.md b/PROMPT_ONE_STEP.md new file mode 100644 index 0000000..18c1667 --- /dev/null +++ b/PROMPT_ONE_STEP.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. diff --git a/QWEN.md b/QWEN.md new file mode 100644 index 0000000..bd7e1e1 --- /dev/null +++ b/QWEN.md @@ -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. diff --git a/STATUS.md b/STATUS.md index 55708f3..4d6f078 100644 --- a/STATUS.md +++ b/STATUS.md @@ -8,18 +8,18 @@ ## Current Focus - **Phase:** 4 -- **Step:** 4.4 +- **Step:** 4.5 - **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 | Item | State | |---|---| -| App starts (`npm start`) | ✅ works (session interval keeps process alive) | -| Tests pass (`npm test`) | ✅ 62/62 passing (4 hunt + 6 auth + 1 health + 51 unit) | -| Migrations run clean on empty DB | ✅ 001_users, 002_characters, 003_content, 004_inventory, 006_logs | +| App starts (`npm start`) | ✅ smoke-tested: register → create hero → 12 hunts → level-up (real seeded zones) | +| 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 (also seeds zones/monsters/items), 004_inventory, 006_logs (005 reserved for expeditions) | | 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. ## 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. *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. *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. - *Acceptance:* 4 integration tests pass; hunt returns winner, rounds, xpGained, goldGained, loot, leveledUp, zone, monster. +- [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:* 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. *Acceptance:* hunting repeatedly drains energy, awards XP/gold, levels the hero, and persists. @@ -112,27 +113,37 @@ ## Known Issues | # | 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`. ## 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) - _none yet_ ## 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 | Suite | Passing | Failing | Last run | |---|---|---|---| -| unit | 51 | 0 | Phase 4.4 | -| integration | 7 | 0 | Phase 4.4 | +| unit | 69 | 0 | 2026-10-20 review pass | +| integration | 24 | 0 | 2026-10-20 review pass | ## Session Log _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 | 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 diff --git a/docs/BALANCE.md b/docs/BALANCE.md index b65ee39..66e5741 100644 --- a/docs/BALANCE.md +++ b/docs/BALANCE.md @@ -27,7 +27,7 @@ On level-up: `hp_current = maxHp`, loop to handle multiple level-ups from one re ## 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) attack = 5 + primary*2 + level + equipment.bonus_attack 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) 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 ``` diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 21ed6cf..89f1ffb 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -31,4 +31,21 @@ Format: - Decision: Static HTML/CSS/JS served by Express. - Consequences: Slightly more DOM boilerplate; far fewer moving parts. - +## 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. + + diff --git a/docs/GAME_DESIGN.md b/docs/GAME_DESIGN.md index 83c513d..e0b9145 100644 --- a/docs/GAME_DESIGN.md +++ b/docs/GAME_DESIGN.md @@ -39,7 +39,7 @@ Each level grants **3 unspent stat points** (player allocates via API). - Derived stats formulas: see BALANCE.md §Derived. ## Combat (auto-resolved, server-side, turn based) -- Hero vs one monster, alternating attacks. Higher `speed` acts first each round. +- 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. - 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. diff --git a/docs/TESTING.md b/docs/TESTING.md index 145c3e0..b9d51e6 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -1,7 +1,9 @@ # TESTING.md Runner: built-in `node:test` + `node:assert/strict`. No Jest/Mocha. -Script: `"test": "node --test tests/"` (add `--test-concurrency=1` if DB tests collide). +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 - `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', ...) })`. ## 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. - New bug → write the failing test first, then fix. - Update the Test Summary table in STATUS.md after each phase. diff --git a/package-lock.json b/package-lock.json index e20a7b2..7993f53 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,6 @@ "bcryptjs": "^3.0.3", "better-sqlite3": "^13.0.3", "better-sqlite3-session-store": "^0.1.0", - "connect-sqlite3": "^0.9.18", "express": "^5.3.0", "express-rate-limit": "^8.7.1", "express-session": "^1.19.0", @@ -19,56 +18,6 @@ "zod": "^4.6.7" } }, - "node_modules/@gar/promisify": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", - "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", - "license": "MIT", - "optional": true - }, - "node_modules/@npmcli/fs": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", - "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==", - "license": "ISC", - "optional": true, - "dependencies": { - "@gar/promisify": "^1.0.1", - "semver": "^7.3.5" - } - }, - "node_modules/@npmcli/move-file": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz", - "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==", - "deprecated": "This functionality has been moved to @npmcli/fs", - "license": "MIT", - "optional": true, - "dependencies": { - "mkdirp": "^1.0.4", - "rimraf": "^3.0.2" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@tootallnate/once": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", - "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/abbrev": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", - "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", - "license": "ISC", - "optional": true - }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -82,106 +31,6 @@ "node": ">= 0.6" } }, - "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/agentkeepalive": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", - "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "humanize-ms": "^1.2.1" - }, - "engines": { - "node": ">= 8.0.0" - } - }, - "node_modules/aggregate-error": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", - "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "license": "MIT", - "optional": true, - "dependencies": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/aproba": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", - "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", - "license": "ISC", - "optional": true - }, - "node_modules/are-we-there-yet": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", - "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", - "deprecated": "This package is no longer supported.", - "license": "ISC", - "optional": true, - "dependencies": { - "delegates": "^1.0.0", - "readable-stream": "^3.6.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT", - "optional": true - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "optional": true - }, "node_modules/bcryptjs": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", @@ -195,6 +44,7 @@ "version": "13.0.3", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", + "hasInstallScript": true, "license": "MIT", "dependencies": { "node-addon-api": "^8.0.0" @@ -212,28 +62,6 @@ "date-fns": "2.16.1" } }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "license": "MIT", - "optional": true, - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, "node_modules/body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", @@ -258,42 +86,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/brace-expansion": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", - "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", - "license": "MIT", - "optional": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "optional": true, - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -303,36 +95,6 @@ "node": ">= 0.8" } }, - "node_modules/cacache": { - "version": "15.3.0", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", - "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==", - "license": "ISC", - "optional": true, - "dependencies": { - "@npmcli/fs": "^1.0.0", - "@npmcli/move-file": "^1.0.1", - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "glob": "^7.1.4", - "infer-owner": "^1.0.4", - "lru-cache": "^6.0.0", - "minipass": "^3.1.1", - "minipass-collect": "^1.0.2", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.2", - "mkdirp": "^1.0.3", - "p-map": "^4.0.0", - "promise-inflight": "^1.0.1", - "rimraf": "^3.0.2", - "ssri": "^8.0.1", - "tar": "^6.0.2", - "unique-filename": "^1.1.1" - }, - "engines": { - "node": ">= 10" - } - }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -362,64 +124,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "license": "ISC", - "optional": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/clean-stack": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/color-support": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", - "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", - "license": "ISC", - "optional": true, - "bin": { - "color-support": "bin.js" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "license": "MIT", - "optional": true - }, - "node_modules/connect-sqlite3": { - "version": "0.9.18", - "resolved": "https://registry.npmjs.org/connect-sqlite3/-/connect-sqlite3-0.9.18.tgz", - "integrity": "sha512-ovQ179bGC6ubrEi/Lk77FmZAyaTDqNLCOsJF9FFAjxmIF7NKomWoKP50f3HVffvO3BGgcihE9kDow9TyhDywiA==", - "engines": { - "node": ">=0.4.x" - }, - "optionalDependencies": { - "sqlite3": "^5.0.0" - }, - "peerDependencies": { - "express-session": "^1.0.0" - } - }, - "node_modules/console-control-strings": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", - "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", - "license": "ISC", - "optional": true - }, "node_modules/content-disposition": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz", @@ -494,39 +198,6 @@ } } }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/delegates": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", - "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", - "license": "MIT", - "optional": true - }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -536,16 +207,6 @@ "node": ">= 0.8" } }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "optional": true, - "engines": { - "node": ">=8" - } - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -566,13 +227,6 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT", - "optional": true - }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -582,56 +236,6 @@ "node": ">= 0.8" } }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "license": "MIT", - "optional": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, - "node_modules/encoding/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", - "optional": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "license": "MIT", - "optional": true, - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/err-code": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", - "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", - "license": "MIT", - "optional": true - }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -677,16 +281,6 @@ "node": ">= 0.6" } }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "license": "(MIT OR WTFPL)", - "optional": true, - "engines": { - "node": ">=6" - } - }, "node_modules/express": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/express/-/express-5.3.0.tgz", @@ -793,13 +387,6 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "license": "MIT", - "optional": true - }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -839,33 +426,6 @@ "node": ">= 0.8" } }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "license": "MIT", - "optional": true - }, - "node_modules/fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "license": "ISC", - "optional": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "license": "ISC", - "optional": true - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -875,27 +435,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gauge": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", - "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", - "deprecated": "This package is no longer supported.", - "license": "ISC", - "optional": true, - "dependencies": { - "aproba": "^1.0.3 || ^2.0.0", - "color-support": "^1.1.3", - "console-control-strings": "^1.1.0", - "has-unicode": "^2.0.1", - "signal-exit": "^3.0.7", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wide-align": "^1.1.5" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -933,35 +472,6 @@ "node": ">= 0.4" } }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "license": "MIT", - "optional": true - }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", - "optional": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -974,13 +484,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "license": "ISC", - "optional": true - }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -993,13 +496,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-unicode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", - "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", - "license": "ISC", - "optional": true - }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -1024,13 +520,6 @@ "url": "https://github.com/sponsors/EvanHahn" } }, - "node_modules/http-cache-semantics": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.3.0.tgz", - "integrity": "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==", - "license": "BSD-2-Clause", - "optional": true - }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -1051,45 +540,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/http-proxy-agent": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", - "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", - "license": "MIT", - "optional": true, - "dependencies": { - "@tootallnate/once": "1", - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "license": "MIT", - "optional": true, - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/humanize-ms": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", - "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "ms": "^2.0.0" - } - }, "node_modules/iconv-lite": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", @@ -1106,79 +556,12 @@ "url": "https://opencollective.com/express" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause", - "optional": true - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/infer-owner": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", - "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", - "license": "ISC", - "optional": true - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "license": "ISC", - "optional": true, - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC", - "optional": true - }, "node_modules/ip-address": { "version": "10.7.4", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.4.tgz", @@ -1197,87 +580,12 @@ "node": ">= 0.10" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-lambda": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", - "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", - "license": "MIT", - "optional": true - }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC", - "optional": true - }, - "node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "license": "ISC", - "optional": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/make-fetch-happen": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", - "integrity": "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==", - "license": "ISC", - "optional": true, - "dependencies": { - "agentkeepalive": "^4.1.3", - "cacache": "^15.2.0", - "http-cache-semantics": "^4.1.0", - "http-proxy-agent": "^4.0.1", - "https-proxy-agent": "^5.0.0", - "is-lambda": "^1.0.1", - "lru-cache": "^6.0.0", - "minipass": "^3.1.3", - "minipass-collect": "^1.0.2", - "minipass-fetch": "^1.3.2", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^0.6.2", - "promise-retry": "^2.0.1", - "socks-proxy-agent": "^6.0.0", - "ssri": "^8.0.0" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/make-fetch-happen/node_modules/negotiator": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", - "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1337,172 +645,12 @@ "url": "https://opencollective.com/express" } }, - "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "license": "ISC", - "optional": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "license": "MIT", - "optional": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "license": "ISC", - "optional": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-collect": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", - "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", - "license": "ISC", - "optional": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minipass-fetch": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.4.1.tgz", - "integrity": "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==", - "license": "MIT", - "optional": true, - "dependencies": { - "minipass": "^3.1.0", - "minipass-sized": "^1.0.3", - "minizlib": "^2.0.0" - }, - "engines": { - "node": ">=8" - }, - "optionalDependencies": { - "encoding": "^0.1.12" - } - }, - "node_modules/minipass-flush": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz", - "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==", - "license": "BlueOak-1.0.0", - "optional": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minipass-pipeline": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", - "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", - "license": "ISC", - "optional": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-sized": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", - "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", - "license": "ISC", - "optional": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "license": "MIT", - "optional": true, - "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "license": "MIT", - "optional": true, - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "license": "MIT", - "optional": true - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/napi-build-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "license": "MIT", - "optional": true - }, "node_modules/negotiator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", @@ -1519,19 +667,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/node-abi": { - "version": "3.99.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.99.0.tgz", - "integrity": "sha512-lLdwkeg2XUVPTHqWjPltTxH81zbQNbUWC+AEb9ni50KjxkUzAAx1HGtMT21sbntzfUGfIphhcklvs3imJeqDXw==", - "license": "MIT", - "optional": true, - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-addon-api": { "version": "8.9.2", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", @@ -1541,64 +676,6 @@ "node": "^18 || ^20 || >= 21" } }, - "node_modules/node-gyp": { - "version": "8.4.1", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", - "integrity": "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==", - "license": "MIT", - "optional": true, - "dependencies": { - "env-paths": "^2.2.0", - "glob": "^7.1.4", - "graceful-fs": "^4.2.6", - "make-fetch-happen": "^9.1.0", - "nopt": "^5.0.0", - "npmlog": "^6.0.0", - "rimraf": "^3.0.2", - "semver": "^7.3.5", - "tar": "^6.1.2", - "which": "^2.0.2" - }, - "bin": { - "node-gyp": "bin/node-gyp.js" - }, - "engines": { - "node": ">= 10.12.0" - } - }, - "node_modules/nopt": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", - "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", - "license": "ISC", - "optional": true, - "dependencies": { - "abbrev": "1" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/npmlog": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", - "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", - "deprecated": "This package is no longer supported.", - "license": "ISC", - "optional": true, - "dependencies": { - "are-we-there-yet": "^3.0.0", - "console-control-strings": "^1.1.0", - "gauge": "^4.0.3", - "set-blocking": "^2.0.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -1641,22 +718,6 @@ "wrappy": "1" } }, - "node_modules/p-map": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", - "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "aggregate-error": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1666,16 +727,6 @@ "node": ">= 0.8" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/path-to-regexp": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", @@ -1686,55 +737,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/prebuild-install": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", - "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", - "license": "MIT", - "optional": true, - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/promise-inflight": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", - "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", - "license": "ISC", - "optional": true - }, - "node_modules/promise-retry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", - "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", - "license": "MIT", - "optional": true, - "dependencies": { - "err-code": "^2.0.2", - "retry": "^0.12.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/proxy-addr": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", @@ -1752,17 +754,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/pump": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", - "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "license": "MIT", - "optional": true, - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, "node_modules/qs": { "version": "6.16.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz", @@ -1816,64 +807,6 @@ "node": ">= 0.10" } }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "optional": true, - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "optional": true, - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 4" - } - }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "license": "ISC", - "optional": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -1916,19 +849,6 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, - "node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "license": "ISC", - "optional": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", @@ -1974,13 +894,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/set-blocking": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "license": "ISC", - "optional": true - }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -2059,146 +972,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "license": "ISC", - "optional": true - }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "optional": true - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "optional": true, - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks": { - "version": "2.8.10", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.10.tgz", - "integrity": "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "ip-address": "^10.1.1", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks-proxy-agent": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz", - "integrity": "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "agent-base": "^6.0.2", - "debug": "^4.3.3", - "socks": "^2.6.2" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/sqlite3": { - "version": "5.1.7", - "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.7.tgz", - "integrity": "sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==", - "hasInstallScript": true, - "license": "BSD-3-Clause", - "optional": true, - "dependencies": { - "bindings": "^1.5.0", - "node-addon-api": "^7.0.0", - "prebuild-install": "^7.1.1", - "tar": "^6.1.11" - }, - "optionalDependencies": { - "node-gyp": "8.x" - }, - "peerDependencies": { - "node-gyp": "8.x" - }, - "peerDependenciesMeta": { - "node-gyp": { - "optional": true - } - } - }, - "node_modules/sqlite3/node_modules/node-addon-api": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", - "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "license": "MIT", - "optional": true - }, - "node_modules/ssri": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", - "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==", - "license": "ISC", - "optional": true, - "dependencies": { - "minipass": "^3.1.1" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -2208,120 +981,6 @@ "node": ">= 0.8" } }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "optional": true, - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "optional": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "optional": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/tar": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", - "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", - "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", - "optional": true, - "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/tar-fs": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", - "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", - "license": "MIT", - "optional": true, - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/tar-fs/node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC", - "optional": true - }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/tar/node_modules/minipass": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "license": "ISC", - "optional": true, - "engines": { - "node": ">=8" - } - }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -2331,19 +990,6 @@ "node": ">=0.6" } }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, "node_modules/type-is": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", @@ -2374,26 +1020,6 @@ "node": ">= 0.8" } }, - "node_modules/unique-filename": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", - "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", - "license": "ISC", - "optional": true, - "dependencies": { - "unique-slug": "^2.0.0" - } - }, - "node_modules/unique-slug": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz", - "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==", - "license": "ISC", - "optional": true, - "dependencies": { - "imurmurhash": "^0.1.4" - } - }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -2403,13 +1029,6 @@ "node": ">= 0.8" } }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT", - "optional": true - }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -2419,45 +1038,12 @@ "node": ">= 0.8" } }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "optional": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/wide-align": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", - "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", - "license": "ISC", - "optional": true, - "dependencies": { - "string-width": "^1.0.2 || 2 || 3 || 4" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC", - "optional": true - }, "node_modules/zod": { "version": "4.6.7", "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", diff --git a/package.json b/package.json index c4fe0b6..9c191b1 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "scripts": { "start": "node 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", "seed": "node scripts/seed.js" }, @@ -20,7 +20,6 @@ "bcryptjs": "^3.0.3", "better-sqlite3": "^13.0.3", "better-sqlite3-session-store": "^0.1.0", - "connect-sqlite3": "^0.9.18", "express": "^5.3.0", "express-rate-limit": "^8.7.1", "express-session": "^1.19.0", diff --git a/public/js/game.js b/public/js/game.js index 88aef83..9b88fb6 100644 --- a/public/js/game.js +++ b/public/js/game.js @@ -1,6 +1,82 @@ // Game dashboard: load character profile and populate stats. 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() { try { @@ -35,8 +111,9 @@ async function loadCharacter() { document.getElementById('derived-spd').textContent = derived.speed; document.getElementById('derived-crit').textContent = derived.critChance; - // Update energy bar + // Update energy bar and start countdown updateEnergyBar(energy.current, energy.max, energy.nextTickInSec); + startCountdown(); } catch (err) { // Not authenticated — redirect to login window.location.href = '/login.html'; @@ -47,47 +124,8 @@ function xpToNext(level) { return Math.floor(100 * Math.pow(level, 1.5)); } -function updateEnergyBar(current, max, nextTickSec) { - // Update text - const energyEl = document.getElementById('char-energy'); - if (energyEl) energyEl.textContent = `${current} / ${max}`; - - // Update bar fill - const barEl = document.getElementById('energy-bar'); - if (barEl) { - const pct = Math.round((current / max) * 100); - barEl.style.width = `${pct}%`; - barEl.title = `${current}/${max} energy`; - } - - // Update countdown - const timerEl = document.getElementById('energy-timer'); - if (timerEl) { - if (current >= max) { - timerEl.textContent = 'Energy full'; - } else { - const mins = Math.floor(nextTickSec / 60); - const secs = nextTickSec % 60; - timerEl.textContent = `Next +1 in ${mins}:${secs.toString().padStart(2, '0')}`; - } - } - - // Start countdown - if (energyInterval) clearInterval(energyInterval); - if (current < max && nextTickSec > 0) { - energyInterval = setInterval(() => { - nextTickSec--; - if (nextTickSec <= 0) { - clearInterval(energyInterval); - loadCharacter(); - return; - } - const mins = Math.floor(nextTickSec / 60); - const secs = nextTickSec % 60; - if (timerEl) timerEl.textContent = `Next +1 in ${mins}:${secs.toString().padStart(2, '0')}`; - }, 1000); - } -} +// Start periodic energy sync from server (every 60s) +startEnergySync(); // Logout handler document.getElementById('logout-btn')?.addEventListener('click', async (e) => { diff --git a/scripts/run-agent-loop.sh b/scripts/run-agent-loop.sh new file mode 100755 index 0000000..052d4b9 --- /dev/null +++ b/scripts/run-agent-loop.sh @@ -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 diff --git a/src/app.js b/src/app.js index 2f20b25..74d0b07 100644 --- a/src/app.js +++ b/src/app.js @@ -53,6 +53,7 @@ function createApp() { if (err instanceof AppError) { return res.status(err.status).json({ + ok: false, error: { code: err.code, message: err.message, @@ -63,6 +64,7 @@ function createApp() { // Check for Zod-like validation errors if (err.name === 'ZodError' || (err.errors && Array.isArray(err.errors))) { return res.status(400).json({ + ok: false, error: { code: 'VALIDATION_ERROR', message: 'Request validation failed.', @@ -72,6 +74,7 @@ function createApp() { // Generic error res.status(500).json({ + ok: false, error: { code: 'INTERNAL_ERROR', message: 'Something went wrong.', diff --git a/src/game/combat.js b/src/game/combat.js index 87143b3..a5b63bf 100644 --- a/src/game/combat.js +++ b/src/game/combat.js @@ -1,111 +1,84 @@ // 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 { 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, maxHp, str, agi, int, vit, bonus_attack, bonus_defense } - * @param {object} monster - { level, hp, attack, defense, speed, name } - * @param {function} rng - PRNG function returning float in [0, 1) - * @returns {object} { winner: 'hero'|'monster', rounds: [{round, actor, damage, crit, targetHpAfter}], hpRemaining } + * @param {object} hero - { class, level, hpCurrent, str, agi, int, vit, bonus_* (optional equipment bonuses) } + * @param {object} monster - { name, level, hp, attack, defense, speed } + * @param {function} rng - PRNG returning a float in [0, 1) + * @returns {{winner: 'hero'|'monster', rounds: object[], hpRemaining: number}} + * hpRemaining is ALWAYS the hero's remaining HP (0 if the hero died). */ 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 monsterHp = monster.hp; const rounds = []; + const order = heroC.speed >= monsterC.speed ? [heroC, monsterC] : [monsterC, heroC]; - for (let round = 1; round <= 20; round++) { - // Determine who acts first (higher speed) - const heroActsFirst = heroStats.speed >= monster.speed; - const attacker = heroActsFirst ? { stats: heroStats, type: 'hero', name: 'Hero' } - : { 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' }; + for (let round = 1; round <= MAX_ROUNDS; round++) { + for (const attacker of order) { + const defender = attacker === heroC ? monsterC : heroC; + const { damage, crit } = resolveAttack(attacker, defender, rng); - // Resolve attack - const result = resolveAttack(attacker, defender, rng); - - if (attacker.type === 'hero') { - 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 { - heroHp -= result.damage; - rounds.push({ round, actor: 'monster', damage: result.damage, crit: result.crit, targetHpAfter: Math.max(0, heroHp) }); - if (heroHp <= 0) { - return { winner: 'monster', rounds, hpRemaining: monsterHp }; + if (attacker === heroC) { + monsterHp -= damage; + rounds.push({ round, actor: 'hero', damage, crit, targetHpAfter: Math.max(0, monsterHp) }); + if (monsterHp <= 0) return { winner: 'hero', rounds, hpRemaining: heroHp }; + } else { + heroHp -= damage; + rounds.push({ round, actor: 'monster', damage, crit, targetHpAfter: Math.max(0, heroHp) }); + if (heroHp <= 0) return { winner: 'monster', rounds, hpRemaining: 0 }; } } } - // 20 rounds — higher HP% wins - const heroPct = heroHp / heroStats.maxHp; + // Round cap reached: higher remaining HP% wins (ties go to the hero). + const heroPct = heroHp / stats.maxHp; const monsterPct = monsterHp / monster.hp; - if (heroPct >= monsterPct) { - 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, - }; + return { winner: heroPct >= monsterPct ? 'hero' : 'monster', rounds, hpRemaining: heroHp }; } function resolveAttack(attacker, defender, rng) { - // Mitigation - const mitigation = 100 / (100 + defender.stats.defense); + const mitigation = 100 / (100 + defender.defense); + let raw = attacker.attack * mitigation; - // Raw damage - let raw = attacker.stats.attack * mitigation; - - // 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; + if (attacker.magicVsHighDef && defender.defense >= MAGE_HIGH_DEF_THRESHOLD) { + raw *= 1 + attacker.magicVsHighDef; } - // Variance: [0.9, 1.1] - const variance = rng() * 0.2 + 0.9; - - // Crit check - 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 }; + const variance = rng() * 0.2 + 0.9; // [0.9, 1.1) + const crit = rng() * 100 < attacker.critChance; + const damage = Math.max(1, Math.round(raw * variance * (crit ? CRIT_MULT : 1))); + return { damage, crit }; } -module.exports = { resolveCombat }; +module.exports = { resolveCombat, MAX_ROUNDS }; diff --git a/src/game/stats.js b/src/game/stats.js index 9d1cdf5..f4d5d33 100644 --- a/src/game/stats.js +++ b/src/game/stats.js @@ -3,6 +3,7 @@ const { getClass } = require('./classes'); 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. @@ -26,8 +27,8 @@ function calcDerived(stats, level, classKey, equipBonuses = {}) { : cls.primary === 'agi' ? totalAgi : totalInt; - // maxHp = 50 + vit*10 + level*5 + itemBonusVit*10 - const maxHp = 50 + totalVit * 10 + level * 5 + (equipBonuses.bonus_vit || 0) * 10; + // maxHp = 50 + totalVit*10 + level*5 (totalVit already includes equipment VIT, so it is counted once) + const maxHp = 50 + totalVit * 10 + level * 5; // attack = 5 + primary*2 + level + equipment.bonus_attack const attack = 5 + primary * 2 + level + (equipBonuses.bonus_attack || 0); @@ -55,6 +56,7 @@ function calcDerived(stats, level, classKey, equipBonuses = {}) { critChance, critMult: 1.5, primary, + primaryStat: cls.primary, }; } @@ -63,44 +65,6 @@ function calcDerived(stats, level, classKey, equipBonuses = {}) { */ const computeEnergy = _computeEnergy; -/** - * XP required to reach next level from given level. - * @param {number} level - current level - * @returns {number} XP needed - */ -function xpToNext(level) { - return Math.floor(100 * Math.pow(level, 1.5)); -} - -/** - * Check if hero leveled up and return new level + unspent points. - * @param {number} currentLevel - current level - * @param {number} currentXp - current XP - * @param {number} xpGained - XP gained from this action - * @returns {object} { newLevel, leveledUp, totalXp, unspentPoints } - */ -function checkLevelUp(currentLevel, currentXp, xpGained) { - let totalXp = currentXp + xpGained; - let level = currentLevel; - let xpNeeded = xpToNext(level); - - while (totalXp >= xpNeeded && level < 50) { - totalXp -= xpNeeded; - level += 1; - xpNeeded = xpToNext(level); - } - - const leveledUp = level > currentLevel; - const unspentPoints = (level - currentLevel) * 3; - - return { - newLevel: level, - leveledUp, - totalXp, - unspentPoints, - }; -} - module.exports = { ENERGY_MAX, ENERGY_REGEN_SECONDS, diff --git a/src/middleware/session.js b/src/middleware/session.js index 941b3b7..de60997 100644 --- a/src/middleware/session.js +++ b/src/middleware/session.js @@ -4,21 +4,29 @@ const config = require('../config'); const { getDb } = require('../db'); const { AppError } = require('../utils/errors'); -function createSessionMiddleware() { - // Use memory store in tests to avoid session store cleanup interval hanging tests - const useMemoryStore = config.nodeEnv === 'test'; +// better-sqlite3-session-store ignores `expired.clear: false` (its code is `clear || true`), so it always +// starts a cleanup setInterval. We unref that timer so it can never keep the process (or a test run) alive. +function createSqliteStore() { + const realSetInterval = global.setInterval; + global.setInterval = (...args) => { + const timer = realSetInterval(...args); + if (timer && typeof timer.unref === 'function') timer.unref(); + return timer; + }; + try { + return new SessionStore({ + client: getDb(), + table: 'sessions', + expired: { clear: true, intervalMs: 15 * 60 * 1000 }, + }); + } finally { + global.setInterval = realSetInterval; + } +} +function createSessionMiddleware() { return session({ - store: useMemoryStore - ? undefined // express-session built-in memory store - : new SessionStore({ - client: getDb(), - table: 'sessions', - expired: { - clear: true, - intervalMs: 900000, // 15 min cleanup - }, - }), + store: createSqliteStore(), // real persistent store in every environment, including tests secret: config.sessionSecret, resave: false, saveUninitialized: false, @@ -38,6 +46,7 @@ function requireAuth(req, res, next) { // Return JSON error for API requests if (req.path.startsWith('/api/') || req.xhr) { return res.status(401).json({ + ok: false, error: { code: 'UNAUTHENTICATED', message: 'Authentication required.', diff --git a/src/routes/auth.js b/src/routes/auth.js index 99d72aa..717240b 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -18,13 +18,13 @@ const loginSchema = zod.strictObject({ const registerLimiter = rateLimit({ windowMs: 60 * 60 * 1000, // 1 hour max: process.env.NODE_ENV === 'test' ? 1000 : 5, - message: { error: { code: 'RATE_LIMITED', message: 'Too many registration attempts. Try again later.' } }, + message: { ok: false, error: { code: 'RATE_LIMITED', message: 'Too many registration attempts. Try again later.' } }, }); const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: process.env.NODE_ENV === 'test' ? 1000 : 10, - message: { error: { code: 'RATE_LIMITED', message: 'Too many login attempts. Try again later.' } }, + message: { ok: false, error: { code: 'RATE_LIMITED', message: 'Too many login attempts. Try again later.' } }, }); function wrap(fn) { diff --git a/src/routes/hunt.js b/src/routes/hunt.js index 8811f6b..875d6c6 100644 --- a/src/routes/hunt.js +++ b/src/routes/hunt.js @@ -3,6 +3,7 @@ const zod = require('zod'); const { AppError } = require('../utils/errors'); const { requireAuth } = require('../middleware/session'); const { hunt } = require('../services/huntService'); +const { getCharacterIdForUser } = require('../services/characterService'); // Validation schema const huntSchema = zod.strictObject({ @@ -20,16 +21,9 @@ async function handleHunt(req, res) { } const { zoneId } = parsed.data; - const userId = req.session.userId; - // Get character ID for this user - const db = require('../db').getDb(); - 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); + const characterId = getCharacterIdForUser(req.session.userId); + const result = hunt(characterId, zoneId); res.json({ ok: true, diff --git a/src/services/authService.js b/src/services/authService.js index 48c1fe8..9a1ff83 100644 --- a/src/services/authService.js +++ b/src/services/authService.js @@ -1,5 +1,6 @@ const bcrypt = require('bcryptjs'); const { getDb } = require('../db'); +const time = require('../utils/time'); const { AppError } = require('../utils/errors'); const BCRYPT_COST = 12; @@ -38,7 +39,7 @@ async function register(username, password) { } 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 user = { id: result.lastInsertRowid, username }; @@ -68,7 +69,7 @@ async function verifyLogin(username, password) { } // 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; return safeUser; diff --git a/src/services/characterService.js b/src/services/characterService.js index d44723d..e46e6d6 100644 --- a/src/services/characterService.js +++ b/src/services/characterService.js @@ -1,11 +1,11 @@ // Character service: create and retrieve characters with derived stats and energy. const { getDb } = require('../db'); +const time = require('../utils/time'); const { AppError } = require('../utils/errors'); 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 ENERGY_UPDATED_AT_DEFAULT = Date.now(); function wrapStmts(db) { return { @@ -41,7 +41,7 @@ function createCharacter(userId, name, classKey) { const db = getDb(); const stmts = getStmts(); const baseStats = getBaseStats(classKey); - const now = Date.now(); + const now = time.now(); // Check if user already has a character 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); } - 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( userId, name, classKey, 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 }; @@ -85,7 +85,7 @@ function getCharacterProfile(userId) { ); // 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 if (energy.energy !== character.energy || energy.updatedAtMs !== character.energy_updated_at) { @@ -99,9 +99,10 @@ function getCharacterProfile(userId) { } // Calculate next energy tick - const now = Date.now(); - const nextTickMs = character.energy_updated_at + (ENERGY_MAX - character.energy) * 180 * 1000; - const nextTickSec = Math.max(0, Math.ceil((nextTickMs - now) / 1000)); + const now = time.now(); + // Seconds until the NEXT +1 energy (0 when full). Not the time until full. + 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 { character: { @@ -146,7 +147,7 @@ function getCharacterProfile(userId) { */ function spendEnergy(characterId, cost) { const db = getDb(); - const now = Date.now(); + const now = time.now(); // Validate 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 }; } -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 }; diff --git a/src/services/huntService.js b/src/services/huntService.js index dbf8539..8a4446a 100644 --- a/src/services/huntService.js +++ b/src/services/huntService.js @@ -1,284 +1,169 @@ -// Hunt service: resolves a hunting attempt in a zone. -// Combines energy spending, monster selection, combat, rewards, and logging -// all within a single database transaction. +// Hunt service: resolves one hunting attempt in a zone. +// Everything (reads, energy spend, combat, rewards, loot, log) happens in ONE transaction. +const crypto = require('node:crypto'); const { getDb } = require('../db'); const { AppError } = require('../utils/errors'); -const { computeEnergy } = require('../game/energy'); -const { resolveCombat } = require('../game/combat'); -const { calculateRewards, rollLoot, materialSellPrice } = require('../game/rewards'); -const { checkLevelUp } = require('../game/leveling'); +const time = require('../utils/time'); 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) { - return { - findCharacterById: db.prepare( - 'SELECT * FROM characters WHERE id = ?' - ), - findZoneById: db.prepare( - 'SELECT * FROM zones WHERE id = ?' - ), - selectMonstersByZone: db.prepare( - 'SELECT * FROM monsters WHERE zone_id = ?' - ), - selectLootByMonster: 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) - 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 = ?' - ), - }; +const MIN_HP_PCT_TO_HUNT = 0.2; +const DEFEAT_GOLD_LOSS_PCT = 0.10; + +// 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' + ), + addToStack: db.prepare('UPDATE inventory SET quantity = quantity + ? WHERE id = ?'), + insertInv: db.prepare( + 'INSERT INTO inventory (character_id, item_id, quantity, acquired_at) VALUES (?, ?, ?, ?)' + ), + updateChar: db.prepare( + `UPDATE characters SET hp_current = ?, energy = ?, energy_updated_at = ?, xp = ?, gold = ?, + level = ?, unspent_points = ?, kills = ?, deaths = ? WHERE id = ?` + ), + insertLog: db.prepare( + `INSERT INTO combat_log (character_id, monster_id, won, rounds, xp_gained, gold_gained, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ), + }; + stmtCache.set(db, s); + } + return s; } -let cachedStmts; - -function getStmts() { - if (!cachedStmts) { - const db = getDb(); - cachedStmts = wrapStmts(db); - } - 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; +function pickWeightedMonster(monsters, rng) { + const total = monsters.reduce((sum, m) => sum + m.weight, 0); + let r = rng() * total; + for (const m of monsters) { + r -= m.weight; + if (r < 0) return m; } 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 }; diff --git a/src/utils/time.js b/src/utils/time.js new file mode 100644 index 0000000..e6c6139 --- /dev/null +++ b/src/utils/time.js @@ -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 }; diff --git a/tests/combat.test.js b/tests/combat.test.js deleted file mode 100644 index b8039ba..0000000 --- a/tests/combat.test.js +++ /dev/null @@ -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); - } - }); -}); diff --git a/tests/helpers.js b/tests/helpers.js index 7ea95db..b91af01 100644 --- a/tests/helpers.js +++ b/tests/helpers.js @@ -10,17 +10,13 @@ let dbRef; function createFreshApp() { process.env.DB_PATH = dbPath; - const cached = [ - require.resolve('../src/db'), - require.resolve('../src/config'), - require.resolve('../src/middleware/session'), - require.resolve('../src/services/authService'), - require.resolve('../src/services/huntService'), - require.resolve('../src/app'), - require.resolve('../scripts/migrate'), - ]; - for (const id of cached) { - if (require.cache[id]) delete require.cache[id]; + // Drop EVERY app module from the require cache so each test gets a fresh app + DB handle. + // (A hand-picked list missed routes/*, which kept services bound to a previous, closed DB.) + const root = path.resolve(__dirname, '..'); + for (const id of Object.keys(require.cache)) { + if (id.startsWith(path.join(root, 'src') + path.sep) || id.startsWith(path.join(root, 'scripts') + path.sep)) { + delete require.cache[id]; + } } const { runMigrations } = require('../scripts/migrate'); diff --git a/tests/auth.test.js b/tests/integration/auth.test.js similarity index 98% rename from tests/auth.test.js rename to tests/integration/auth.test.js index 3e63bd1..b503501 100644 --- a/tests/auth.test.js +++ b/tests/integration/auth.test.js @@ -1,7 +1,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const http = require('node:http'); -const { setup, teardown, cleanup } = require('./helpers'); +const { setup, teardown, cleanup } = require('../helpers'); // Simple cookie jar for tests function createJar() { diff --git a/tests/integration/character-service.test.js b/tests/integration/character-service.test.js new file mode 100644 index 0000000..542381a --- /dev/null +++ b/tests/integration/character-service.test.js @@ -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 + }); +}); diff --git a/tests/health.test.js b/tests/integration/health.test.js similarity index 95% rename from tests/health.test.js rename to tests/integration/health.test.js index b622766..73254c3 100644 --- a/tests/health.test.js +++ b/tests/integration/health.test.js @@ -1,7 +1,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const http = require('node:http'); -const { createApp } = require('../src/app'); +const { createApp } = require('../../src/app'); function startServer(app) { return new Promise((resolve) => { diff --git a/tests/integration/hunt-service.test.js b/tests/integration/hunt-service.test.js new file mode 100644 index 0000000..3e4136b --- /dev/null +++ b/tests/integration/hunt-service.test.js @@ -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)); + }); +}); diff --git a/tests/unit/combat.test.js b/tests/unit/combat.test.js new file mode 100644 index 0000000..1034eee --- /dev/null +++ b/tests/unit/combat.test.js @@ -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)); + }); +});