- 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
266 lines
9.5 KiB
Python
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")
|