feat: complete Phase 4 combat/hunting system

- Add content migration (003_content.sql): zones, monsters, items, loot tables
- Add inventory migration (004_inventory.sql) and logs migration (006_logs.sql)
- Add Mulberry32 PRNG (src/utils/rng.js) for deterministic combat
- Add combat system (src/game/combat.js) with turn order, crit, mage bonus, 20-round cap
- Add rewards/loot system (src/game/rewards.js) and leveling (src/game/leveling.js)
- Add hunt endpoint (POST /api/hunt) with full combat flow in DB transaction
- Add hunt integration tests and unit tests for rewards/leveling/combat
- Fix session middleware to use memory store in tests (avoids cleanup hang)
- Fix huntService transaction (db.transaction() returns wrapper, must call separately)
- Add huntService to helpers.js cache invalidation for test isolation
This commit is contained in:
bepis
2026-10-20 17:46:18 +11:00
parent 7a3e408ffc
commit f1ae7b3119
16 changed files with 1239 additions and 21 deletions
+145
View File
@@ -0,0 +1,145 @@
-- 003_content.sql — Static game content: zones, monsters, items, loot tables.
-- Seeds use INSERT ... ON CONFLICT DO UPDATE so re-running is safe.
CREATE TABLE IF NOT EXISTS zones (
id INTEGER PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT NOT NULL,
min_level INTEGER NOT NULL,
energy_cost INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS monsters (
id INTEGER PRIMARY KEY,
zone_id INTEGER NOT NULL REFERENCES zones(id),
name TEXT NOT NULL,
level INTEGER NOT NULL,
hp INTEGER NOT NULL,
attack INTEGER NOT NULL,
defense INTEGER NOT NULL,
speed INTEGER NOT NULL,
xp INTEGER NOT NULL,
gold_min INTEGER NOT NULL,
gold_max INTEGER NOT NULL,
weight INTEGER NOT NULL DEFAULT 10
);
CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT NOT NULL,
type TEXT NOT NULL CHECK (type IN ('weapon','armor','trinket','consumable','material')),
rarity TEXT NOT NULL CHECK (rarity IN ('common','uncommon','rare','epic','legendary')),
min_level INTEGER NOT NULL DEFAULT 1,
buy_price INTEGER NOT NULL DEFAULT 0,
stackable INTEGER NOT NULL DEFAULT 0 CHECK (stackable IN (0,1)),
bonus_str INTEGER NOT NULL DEFAULT 0,
bonus_agi INTEGER NOT NULL DEFAULT 0,
bonus_int INTEGER NOT NULL DEFAULT 0,
bonus_vit INTEGER NOT NULL DEFAULT 0,
bonus_attack INTEGER NOT NULL DEFAULT 0,
bonus_defense INTEGER NOT NULL DEFAULT 0,
effect TEXT
);
CREATE TABLE IF NOT EXISTS loot_table (
monster_id INTEGER NOT NULL REFERENCES monsters(id),
item_id INTEGER NOT NULL REFERENCES items(id),
chance_pct REAL NOT NULL CHECK (chance_pct > 0 AND chance_pct <= 100),
qty_min INTEGER NOT NULL DEFAULT 1,
qty_max INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (monster_id, item_id)
);
-- ===== ZONES =====
INSERT OR IGNORE INTO zones (id, slug, name, description, min_level, energy_cost) VALUES
(1, 'whispering-meadows', 'Whispering Meadows', 'Rolling green hills dotted with wildflowers — and hidden goblin burrows.', 1, 5),
(2, 'gloomroot-forest', 'Gloomroot Forest', 'A dark, ancient forest where the canopy blocks out most of the sunlight.', 5, 8),
(3, 'ashfang-crag', 'Ashfang Crags', 'Volcanic rock and ash-filled winds. Dragons make their homes in these crags.', 10, 12);
-- ===== MONSTERS =====
INSERT OR IGNORE INTO monsters (id, zone_id, name, level, hp, attack, defense, speed, xp, gold_min, gold_max, weight) VALUES
-- Whispering Meadows
(1, 1, 'Giant Rat', 1, 20, 6, 1, 12, 8, 1, 3, 30),
(2, 1, 'Meadow Wolf', 2, 32, 9, 3, 16, 14, 2, 5, 20),
(3, 1, 'Goblin Scrapper', 3, 45, 12, 5, 11, 22, 4, 9, 15),
-- Gloomroot Forest
(4, 2, 'Webspinner', 5, 70, 18, 8, 15, 40, 8, 16, 25),
(5, 2, 'Bandit Cutthroat', 6, 90, 22, 12, 14, 55, 12, 24, 20),
(6, 2, 'Mossback Treant', 8, 150, 26, 22, 7, 85, 15, 30, 10),
-- Ashfang Crags
(7, 3, 'Ember Imp', 10, 130, 34, 15, 20, 120, 25, 45, 25),
(8, 3, 'Stone Golem', 12, 260, 38, 40, 6, 170, 30, 60, 10),
(9, 3, 'Ashfang Drake', 14, 300, 50, 30, 18, 260, 50, 100, 5);
-- ===== ITEMS =====
INSERT OR IGNORE INTO items (id, slug, name, description, type, rarity, min_level, buy_price, stackable, bonus_str, bonus_agi, bonus_int, bonus_vit, bonus_attack, bonus_defense, effect) VALUES
-- Weapons (common)
(1, 'rusty-sword', 'Rusty Sword', 'A worn blade, but still sharp enough.', 'weapon', 'common', 1, 20, 0, 0, 0, 0, 0, 3, 0, NULL),
(2, 'apprentice-staff', 'Apprentice Staff', 'Carved by a fledgling mage.', 'weapon', 'common', 1, 20, 0, 0, 0, 0, 1, 2, 0, NULL),
(3, 'hunters-shortbow', 'Hunters Shortbow', 'Reliable and lightweight.', 'weapon', 'common', 1, 20, 0, 0, 2, 0, 1, 0, 0, NULL),
-- Weapons (uncommon)
(4, 'iron-blade', 'Iron Blade', 'Sturdy and well-balanced.', 'weapon', 'uncommon', 5, 120, 0, 2, 0, 0, 0, 8, 0, NULL),
(5, 'oakwood-staff', 'Oakwood Staff', 'Ancient oak, imbued with subtle magic.', 'weapon', 'uncommon', 5, 120, 0, 0, 0, 3, 0, 6, 0, NULL),
(6, 'yew-longbow', 'Yew Longbow', 'Fast and accurate.', 'weapon', 'uncommon', 5, 120, 0, 0, 3, 0, 0, 6, 0, NULL),
-- Weapons (rare)
(7, 'emberforged-sword', 'Emberforged Greatsword', 'Forged in dragonfire.', 'weapon', 'rare', 10, 600, 0, 4, 0, 0, 0, 18, 0, NULL),
-- Armor (common)
(8, 'leather-jerkin', 'Leather Jerkin', 'Basic leather protection.', 'armor', 'common', 1, 25, 0, 0, 0, 0, 0, 0, 3, NULL),
-- Armor (uncommon)
(9, 'chainmail-vest', 'Chainmail Vest', 'Linked steel rings provide solid defense.', 'armor', 'uncommon', 5, 140, 0, 0, 0, 0, 2, 0, 9, NULL),
-- Armor (rare)
(10, 'drake-scale-armor', 'Drake-scale Armor', 'Scales tempered in dragonfire.', 'armor', 'rare', 12, 700, 0, 0, 0, 0, 4, 0, 22, NULL),
-- Trinkets (common)
(11, 'wooden-charm', 'Wooden Charm', 'A simple carved talisman.', 'trinket','common', 1, 30, 0, 0, 0, 0, 1, 0, 0, NULL),
-- Trinkets (uncommon)
(12, 'wolf-fang-pendant', 'Wolf-fang Pendant', 'A fearsome trophy.', 'trinket','uncommon', 3, 90, 0, 0, 2, 0, 0, 0, 0, NULL),
-- Trinkets (rare)
(13, 'ember-ring', 'Ember Ring', 'Warm to the touch.', 'trinket','rare', 10, 450, 0, 3, 0, 3, 0, 0, 0, NULL),
-- Consumables
(14, 'minor-healing-potion', 'Minor Healing Potion', 'Restores 50 HP.', 'consumable','common', 1, 15, 1, 0, 0, 0, 0, 0, 0, 'heal:50'),
(15, 'greater-healing-potion', 'Greater Healing Potion', 'Restores 150 HP.', 'consumable','uncommon', 8, 60, 1, 0, 0, 0, 0, 0, 0, 'heal:150'),
(16, 'energy-tonic', 'Energy Tonic', 'Restores 25 energy.', 'consumable','uncommon', 1, 80, 1, 0, 0, 0, 0, 0, 0, 'energy:25'),
-- Materials
(17, 'rat-tail', 'Rat Tail', 'A slimy rat tail.', 'material','common', 1, 0, 1, 0, 0, 0, 0, 0, 0, NULL),
(18, 'wolf-pelt', 'Wolf Pelt', 'A thick wolf pelt.', 'material','common', 2, 0, 1, 0, 0, 0, 0, 0, 0, NULL),
(19, 'spider-silk','Spider Silk', 'Gossamer strands of silk.', 'material','uncommon', 5, 0, 1, 0, 0, 0, 0, 0, 0, NULL),
(20, 'imp-horn', 'Imp Horn', 'A curled imp horn.', 'material','uncommon', 10, 0, 1, 0, 0, 0, 0, 0, 0, NULL),
(21, 'drake-scale','Drake Scale', 'A single scales from an Ashfang Drake.', 'material','rare', 14, 0, 1, 0, 0, 0, 0, 0, 0, NULL);
-- ===== LOOT TABLE =====
-- Common materials (40% from weakest monsters in zone)
INSERT OR IGNORE INTO loot_table (monster_id, item_id, chance_pct, qty_min, qty_max) VALUES
(1, 17, 40, 1, 2), -- Giant Rat → Rat Tail
(2, 18, 40, 1, 2), -- Meadow Wolf → Wolf Pelt
(4, 19, 40, 1, 2), -- Webspinner → Spider Silk
(7, 20, 40, 1, 2), -- Ember Imp → Imp Horn
(9, 21, 40, 1, 2); -- Ashfang Drake → Drake Scale
-- Uncommon gear drops (3-5%)
INSERT OR IGNORE INTO loot_table (monster_id, item_id, chance_pct, qty_min, qty_max) VALUES
(3, 4, 5, 1, 1), -- Goblin Scrapper → Iron Blade
(3, 5, 5, 1, 1), -- Goblin Scrapper → Oakwood Staff
(3, 6, 5, 1, 1), -- Goblin Scrapper → Yew Longbow
(6, 7, 5, 1, 1), -- Mossback Treant → Emberforged Greatsword
(6, 9, 4, 1, 1), -- Mossback Treant → Chainmail Vest
(9, 10, 3, 1, 1), -- Ashfang Drake → Drake-scale Armor
(9, 13, 3, 1, 1); -- Ashfang Drake → Ember Ring
-- Rare gear drops (0.5-1%)
INSERT OR IGNORE INTO loot_table (monster_id, item_id, chance_pct, qty_min, qty_max) VALUES
(9, 7, 1, 1, 1); -- Ashfang Drake → Emberforged Greatsword (rare drop)
-- Healing potions (8% anywhere)
INSERT OR IGNORE INTO loot_table (monster_id, item_id, chance_pct, qty_min, qty_max) VALUES
(1, 14, 8, 1, 1),
(2, 14, 8, 1, 1),
(3, 14, 8, 1, 1),
(4, 14, 8, 1, 1),
(5, 14, 8, 1, 1),
(6, 15, 8, 1, 1),
(7, 15, 8, 1, 1),
(8, 15, 8, 1, 1),
(9, 15, 8, 1, 1);
+16
View File
@@ -0,0 +1,16 @@
-- 004_inventory.sql — Player inventory and equipment slots.
CREATE TABLE IF NOT EXISTS inventory (
id INTEGER PRIMARY KEY AUTOINCREMENT,
character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
item_id INTEGER NOT NULL REFERENCES items(id),
quantity INTEGER NOT NULL DEFAULT 1 CHECK (quantity >= 1),
equipped_slot TEXT CHECK (equipped_slot IN ('weapon', 'armor', 'trinket')),
acquired_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_inventory_char ON inventory(character_id);
-- One equipped item per slot per character (partial unique index).
CREATE UNIQUE INDEX IF NOT EXISTS idx_inventory_equipped
ON inventory(character_id, equipped_slot) WHERE equipped_slot IS NOT NULL;
+22
View File
@@ -0,0 +1,22 @@
-- 006_logs.sql — Combat log and audit log for security/replay.
CREATE TABLE IF NOT EXISTS combat_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
monster_id INTEGER NOT NULL REFERENCES monsters(id),
won INTEGER NOT NULL CHECK (won IN (0, 1)),
rounds INTEGER NOT NULL,
xp_gained INTEGER NOT NULL,
gold_gained INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_combat_log_char ON combat_log(character_id, created_at DESC);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
event TEXT NOT NULL,
ip TEXT,
created_at INTEGER NOT NULL
);