valheim-map/src/valheim_db.py
Aelith 3a08501b0d Initial commit: Nerdhalla Valheim World Map
- Phase 1: POI database (11,309 locations, SQLite with spatial queries)
- Phase 2: Tile decoder + Leaflet.js web viewer (3 map layers, search, categories)
- Phase 3: .db/.fch parser WIP for fog-of-war overlay
- Full README with roadmap, architecture, and usage docs
2026-06-16 13:18:39 -04:00

266 lines
9.5 KiB
Python

#!/usr/bin/env python3
"""Valheim World POI Database Builder
Parses locations.json into a queryable SQLite database with spatial support.
"""
import json, sqlite3, re, sys
from collections import Counter
DB_PATH = "/tmp/valheim_poi.db"
JSON_PATH = "/tmp/valheim_locations.json"
# ── Parse position strings like "(1.8, 81.4, 0.0)" ──
def parse_pos(s):
"""Returns (x, y_height, z) — x/z are map coords, y is altitude."""
m = re.match(r'\(?(-?[\d.]+),\s*(-?[\d.]+),\s*(-?[\d.]+)\)?', str(s))
if m:
return float(m.group(1)), float(m.group(2)), float(m.group(3))
return None, None, None
def classify_prefab(name):
"""Classify a prefab into a category for querying."""
categories = {
'boss': ['Eikthyrnir', 'Bonemass', 'GDKing', 'GoblinKing', 'Moder', 'Yagluth', 'SeekerQueen'],
'dungeon': ['Crypt', 'SunkenCrypt', 'Cave', 'DvergrTown', 'Mistlands_Dungeon'],
'vegvisir': ['Vegvisir'],
'altar': ['OfferingPlace', 'Runestone', 'StoneHenge'],
'vendor': ['Vendor_BlackForest', 'Haldor', 'Hildir'],
'structure': ['Castle', 'Tower', 'Ruin', 'Fortress', 'Camp'],
'resource': ['TarPit', 'Obsidian', 'Silver', 'Copper', 'Tin', 'DragonEgg', 'Mistlands_Guards'],
'spawner': ['Spawner', 'SpawnArea'],
'point_of_interest': ['StartTemple', 'FulingVillage', 'VikingBroad', 'StoneBridge'],
}
for cat, patterns in categories.items():
for p in patterns:
if p.lower() in name.lower():
return cat
return 'other'
def create_db(conn):
c = conn.cursor()
c.executescript("""
DROP TABLE IF EXISTS locations;
DROP TABLE IF EXISTS important_contents;
DROP TABLE IF EXISTS random_contents;
DROP TABLE IF EXISTS dungeon_components;
DROP TABLE IF EXISTS prefab_categories;
CREATE TABLE locations (
id INTEGER PRIMARY KEY,
prefab_name TEXT NOT NULL,
category TEXT NOT NULL,
pos_x REAL,
pos_y REAL, -- altitude/height
pos_z REAL, -- map depth
has_dungeon INTEGER DEFAULT 0,
has_important INTEGER DEFAULT 0,
has_random INTEGER DEFAULT 0
);
CREATE TABLE important_contents (
id INTEGER PRIMARY KEY,
location_id INTEGER NOT NULL,
friendly_name TEXT,
count INTEGER,
icon TEXT,
FOREIGN KEY (location_id) REFERENCES locations(id)
);
CREATE TABLE random_contents (
id INTEGER PRIMARY KEY,
location_id INTEGER NOT NULL,
type TEXT,
parent TEXT,
name TEXT,
interior INTEGER,
FOREIGN KEY (location_id) REFERENCES locations(id)
);
CREATE TABLE dungeon_components (
id INTEGER PRIMARY KEY,
location_id INTEGER NOT NULL,
delta_x REAL,
delta_y REAL,
rotation_y REAL,
parent_type TEXT,
parent_name TEXT,
FOREIGN KEY (location_id) REFERENCES locations(id)
);
CREATE INDEX idx_locations_prefab ON locations(prefab_name);
CREATE INDEX idx_locations_category ON locations(category);
CREATE INDEX idx_locations_pos ON locations(pos_x, pos_z);
CREATE INDEX idx_important_name ON important_contents(friendly_name);
CREATE INDEX idx_random_type ON random_contents(type, parent);
""")
conn.commit()
def parse_and_insert(conn):
c = conn.cursor()
loc_id = 0
imp_id = 0
rnd_id = 0
cmp_id = 0
prefab_counter = Counter()
category_counter = Counter()
imp_counter = Counter()
print("Parsing locations.json...")
with open(JSON_PATH, 'r', encoding='utf-8') as f:
data = json.load(f)
total = len(data)
print(f"Total locations: {total}")
for i, loc in enumerate(data):
prefab = loc.get('PrefabName', 'Unknown')
pos_str = loc.get('Position', '')
x, y, z = parse_pos(pos_str)
category = classify_prefab(prefab)
prefab_counter[prefab] += 1
category_counter[category] += 1
has_dungeon = 1 if 'DungeonComponents' in loc and loc['DungeonComponents'] else 0
has_important = 1 if 'ImportantContents' in loc and loc['ImportantContents'] else 0
has_random = 1 if 'RandomContents' in loc and loc['RandomContents'] else 0
loc_id += 1
c.execute("""
INSERT INTO locations (id, prefab_name, category, pos_x, pos_y, pos_z,
has_dungeon, has_important, has_random)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (loc_id, prefab, category, x, y, z, has_dungeon, has_important, has_random))
# Important contents
for ic in loc.get('ImportantContents', []):
imp_id += 1
friendly = ic.get('FriendlyName', '')
count = ic.get('Count', 1)
icon = ic.get('Icon', '')
imp_counter[friendly] += count
c.execute("""
INSERT INTO important_contents (id, location_id, friendly_name, count, icon)
VALUES (?, ?, ?, ?, ?)
""", (imp_id, loc_id, friendly, count, icon))
# Random contents
for rc in loc.get('RandomContents', []):
rnd_id += 1
c.execute("""
INSERT INTO random_contents (id, location_id, type, parent, name, interior)
VALUES (?, ?, ?, ?, ?, ?)
""", (rnd_id, loc_id, rc.get('Type',''), rc.get('Parent',''),
rc.get('Name',''), rc.get('Interior',0)))
# Dungeon components
for dc in loc.get('DungeonComponents', []):
cmp_id += 1
ik = dc.get('ImageKey', {})
c.execute("""
INSERT INTO dungeon_components (id, location_id, delta_x, delta_y,
rotation_y, parent_type, parent_name)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (cmp_id, loc_id, dc.get('DeltaX',0), dc.get('DeltaY',0),
dc.get('RotationY',0), ik.get('Type',''), ik.get('Parent','')))
if (i + 1) % 5000 == 0:
print(f" Processed {i+1}/{total}...")
conn.commit()
conn.commit()
print("\n=== Summary ===")
print(f"Locations: {loc_id}")
print(f"Important contents: {imp_id}")
print(f"Random contents: {rnd_id}")
print(f"Dungeon components: {cmp_id}")
print("\n--- By Category ---")
for cat, cnt in sorted(category_counter.items(), key=lambda x: -x[1]):
print(f" {cat}: {cnt}")
print("\n--- Top 20 Prefabs ---")
for prefab, cnt in prefab_counter.most_common(20):
print(f" {prefab}: {cnt}")
print("\n--- Top 20 Important Contents ---")
for name, cnt in imp_counter.most_common(20):
print(f" {name}: {cnt}")
return conn
def demo_queries(conn):
c = conn.cursor()
print("\n" + "="*60)
print("DEMO QUERIES")
print("="*60)
# 1. Boss locations
print("\n--- Boss Altars ---")
for row in c.execute("""
SELECT prefab_name, pos_x, pos_z FROM locations
WHERE category='boss' ORDER BY prefab_name
"""):
print(f" {row[0]}: ({row[1]:.0f}, {row[2]:.0f})")
# 2. Tar pits
print("\n--- Tar Pits ---")
for row in c.execute("""
SELECT pos_x, pos_z FROM locations WHERE prefab_name LIKE '%TarPit%'
"""):
print(f" Tar Pit at ({row[0]:.0f}, {row[1]:.0f})")
# 3. Surtling cores in dungeons
print("\n--- Surtling Core Locations ---")
for row in c.execute("""
SELECT l.prefab_name, l.pos_x, l.pos_z, SUM(ic.count) as total
FROM locations l JOIN important_contents ic ON l.id = ic.location_id
WHERE ic.friendly_name LIKE '%SurtlingCore%'
GROUP BY l.id ORDER BY total DESC LIMIT 10
"""):
print(f" {row[0]} @ ({row[1]:.0f},{row[2]:.0f}): {row[3]} cores")
# 4. Vegvisirs
print("\n--- Vegvisirs (Boss Hint Stones) ---")
for row in c.execute("""
SELECT ic.friendly_name, l.pos_x, l.pos_z
FROM locations l JOIN important_contents ic ON l.id = ic.location_id
WHERE ic.friendly_name LIKE '%Vegvisir%'
"""):
print(f" {row[0]} at ({row[1]:.0f}, {row[2]:.0f})")
# 5. Tar pit density - find clusters
print("\n--- Tar Pit Cluster Analysis ---")
rows = c.execute("""
SELECT pos_x, pos_z FROM locations WHERE prefab_name LIKE '%TarPit%'
""").fetchall()
if rows:
# Find highest density zone (grid-based)
grid = {}
grid_size = 1000 # 1000 unit cells
for x, z in rows:
gx, gz = int(x // grid_size), int(z // grid_size)
grid[(gx, gz)] = grid.get((gx, gz), 0) + 1
top = sorted(grid.items(), key=lambda x: -x[1])[:5]
for (gx, gz), cnt in top:
center_x = gx * grid_size + grid_size // 2
center_z = gz * grid_size + grid_size // 2
print(f" Zone ({center_x}, {center_z}): {cnt} tar pits in {grid_size}u radius")
if __name__ == '__main__':
import os
if os.path.exists(DB_PATH):
os.remove(DB_PATH)
conn = sqlite3.connect(DB_PATH)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
create_db(conn)
parse_and_insert(conn)
demo_queries(conn)
conn.close()
print(f"\nDatabase saved to {DB_PATH}")
print(f"Size: {os.path.getsize(DB_PATH) / 1024 / 1024:.1f} MB")