Compare commits
No commits in common. "main" and "master" have entirely different histories.
18
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Large data files (stored in /tmp/, not in repo)
|
||||
*.json
|
||||
*.db
|
||||
*.bin.gz
|
||||
|
||||
# Python cache
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.venv
|
||||
venv/
|
||||
409
README.md
|
|
@ -1,150 +1,287 @@
|
|||
# Nerdhalla Valheim World Map
|
||||
|
||||
**Vanilla Valheim community server tools** — no mods, no BepInEx. Everything runs off the world save file.
|
||||
> **Seed:** `yzZ5fr2tGa` | **World:** 24000 × 24000 | **Biomes:** 9 | **POIs:** 11,309
|
||||
|
||||
## 🌐 Live Sites
|
||||
An interactive web map of the Nerdhalla Valheim world, built from a `valheim-map.world` export. Includes biome/height map tiles, a searchable POI database, and a roadmap for fog-of-war integration.
|
||||
|
||||
| Site | URL | Purpose |
|
||||
|---|---|---|
|
||||
| **Nerdhalla Community** | https://nerdhalla.nerdcade.cc | Map viewer, POI browser, server status |
|
||||
| **Forgejo (source)** | https://git.sweeney.fyi/aelith/valheim-map | Git repo for all code |
|
||||
---
|
||||
|
||||
## 🗺️ What's Here
|
||||
|
||||
### Data Pipeline
|
||||
|
||||
| Step | Script | Output |
|
||||
|---|---|---|
|
||||
| Decode tiles | `decode_valheim_tiles.py` | 16 × 1024×1024 PNGs (composite, biome, height) |
|
||||
| Stitch tiles | `valheim_decode_tiles.py` | 4096×4096 world images |
|
||||
| Build POI DB | `build_db.py` | SQLite DB with 11,309 locations |
|
||||
| Build viewer | `build_viewer.py` | Leaflet HTML with 3 map layers |
|
||||
| Parse .db | `parse_valheim_db.py` | ZPackage → JSON conversion |
|
||||
|
||||
### Web Viewer (`index.html`)
|
||||
|
||||
- Leaflet map with 3 layers: composite, biome, height
|
||||
- 11,309 POI markers with category icons (boss, dungeon, resource, etc.)
|
||||
- Coordinate display on hover
|
||||
- POI browser with search + category filter
|
||||
- Server status indicator (checks Nerdcade via SSH)
|
||||
|
||||
### API Server (`api_server.py`)
|
||||
|
||||
- `GET /api/status` — Nerdcade server online/offline check
|
||||
- `GET /api/pois?limit=N&search=X&category=Y` — POI queries
|
||||
|
||||
### Infrastructure
|
||||
|
||||
- **Caddy** — reverse proxy with auto-TLS for nerdhalla.nerdcade.cc + git.sweeney.fyi
|
||||
- **Forgejo** — bare metal (migrated from Docker), git.sweeney.fyi:3000
|
||||
- **nerdhalla-api** — systemd service, port 8081
|
||||
|
||||
## 📊 World Stats
|
||||
|
||||
| Property | Value |
|
||||
|---|---|
|
||||
| Seed | `yzZ5fr2tGa` |
|
||||
| Map Size | ~10 km × 10 km (4096 × 4096 px) |
|
||||
| Biomes | Meadows, Black Forest, Swamp, Mountain, Plains, Ocean, Mistlands |
|
||||
| Total POIs | 11,309 |
|
||||
| Best Tar Pit | (1210, -4159) — 5 tar pits within 200m |
|
||||
|
||||
## 🚧 Roadmap
|
||||
|
||||
### Phase 1 — POI Database ✅
|
||||
- [x] Decode 16 map tiles from world save
|
||||
- [x] Stitch into 4096×4096 composite/biome/height images
|
||||
- [x] Build SQLite DB from `locations.json` (11,309 POIs)
|
||||
- [x] Spatial queries for base location hunting
|
||||
|
||||
### Phase 2 — Web Viewer ✅
|
||||
- [x] Leaflet map with 3 tile layers
|
||||
- [x] POI markers with category icons
|
||||
- [x] Search + category filter
|
||||
- [x] Coordinate display
|
||||
- [x] Server status indicator
|
||||
- [x] TLS via Caddy
|
||||
- [x] API backend for POI queries
|
||||
|
||||
### Phase 3 — Fog-of-War ⏳
|
||||
- [ ] **Blocked:** Need player `.fch` files from local machines
|
||||
- [ ] Extract explored/unexplored bitmap per player
|
||||
- [ ] Overlay on map viewer
|
||||
- [ ] Merge multiple players' exploration data
|
||||
|
||||
### Phase 4 — Cartography Sync 🔮
|
||||
- [ ] **Undecided approach:**
|
||||
- Option 1: ServerSideMap mod (requires BepInEx — some players don't want this)
|
||||
- Option 2: Periodic merge script (pull `.fch` files, merge explored data)
|
||||
- Option 3: Cartography table automation (in-game solution)
|
||||
|
||||
### Phase 5 — Automation 🔮
|
||||
- [ ] Cron job to periodically pull `Nerdhalla.db` from Nerdcade
|
||||
- [ ] Auto-regenerate map tiles on world update
|
||||
- [ ] Push updated data to site
|
||||
|
||||
## 🛠️ Infrastructure
|
||||
|
||||
### Services on Boston-VPS
|
||||
|
||||
| Service | Type | Port | Notes |
|
||||
|---|---|---|---|
|
||||
| Caddy | Bare metal | 80/443 | Reverse proxy, auto-TLS |
|
||||
| Forgejo | Bare metal | 3000 | Git server, git.sweeney.fyi |
|
||||
| nerdhalla-api | Bare metal | 8081 | Python API server |
|
||||
| Hermes Gateway | Bare metal | 8642/8644 | Fleet COO agent |
|
||||
|
||||
### Docker Cleanup (June 2026)
|
||||
- Forgejo migrated from Docker to bare metal (saved ~450 MB virtual overhead)
|
||||
- Dead containers removed: `hermes-fa98be67` (exited), `vllm-compression` (never started)
|
||||
- 9.2 GB reclaimed from unused images
|
||||
- Docker still installed but only used if needed
|
||||
|
||||
## 🔧 Quick Commands
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Restart API
|
||||
sudo systemctl restart nerdhalla-api
|
||||
|
||||
# Restart Forgejo
|
||||
sudo systemctl restart forgejo
|
||||
|
||||
# Restart Caddy
|
||||
sudo systemctl reload caddy
|
||||
|
||||
# View logs
|
||||
sudo journalctl -u nerdhalla-api -n 50 --no-pager
|
||||
sudo journalctl -u forgejo -n 50 --no-pager
|
||||
sudo tail -f /var/log/caddy/nerdhalla.log
|
||||
# Serve the map viewer
|
||||
cd ~/projects/valheim-map/output && python3 -m http.server 8765
|
||||
# Open: http://72.60.69.120:8765/index.html
|
||||
```
|
||||
|
||||
## 📁 File Layout
|
||||
**Live:** http://72.60.69.120:8765/index.html (port 8765, ufw allowed)
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
/var/www/nerdhalla/ # Site root
|
||||
├── index.html # Main site
|
||||
├── api_server.py # Python API
|
||||
├── world_composite.png # 4096×4096 map images
|
||||
├── world_biome.png
|
||||
├── world_height.png
|
||||
├── 00-00_*.png # Individual tile images (16 tiles × 3 layers)
|
||||
└── ...
|
||||
|
||||
/srv/forgejo/ # Forgejo data
|
||||
└── data/
|
||||
├── gitea/ # Config, DB, attachments
|
||||
└── git/repositories/ # Git repos
|
||||
|
||||
/etc/caddy/
|
||||
├── Caddyfile # Main config (imports sites-enabled/)
|
||||
└── sites-enabled/
|
||||
├── nerdhalla.conf # nerdhalla.nerdcade.cc
|
||||
└── git.conf # git.sweeney.fyi
|
||||
~/projects/valheim-map/
|
||||
├── src/ # Source scripts
|
||||
│ ├── valheim_db.py # Phase 1: Build POI SQLite database from locations.json
|
||||
│ ├── decode_valheim_tiles.py # Phase 2: Decode .bin.gz tiles → PNG images
|
||||
│ ├── build_viewer.py # Phase 2: Generate Leaflet.js web viewer
|
||||
│ ├── parse_valheim_db.py # Phase 3: Attempted .db parser (WIP)
|
||||
│ └── parse_valheim_zpackage.py # Phase 3: ZPackage format exploration (WIP)
|
||||
├── data/ # Data files (small)
|
||||
│ ├── valheim_poi.db # SQLite POI database (55 MB)
|
||||
│ └── Nerdhalla.fwl # World metadata file (499 bytes)
|
||||
├── output/ # Generated map assets
|
||||
│ ├── index.html # Leaflet.js web viewer (5.4 MB)
|
||||
│ ├── world_composite.png # Full world map, biome+height (3.7 MB)
|
||||
│ ├── world_biome.png # Full world biome classification (374 KB)
|
||||
│ ├── world_height.png # Full world heightmap (2.4 MB)
|
||||
│ ├── XX-YY_*.png # Individual tile images (16 tiles × 3 layers)
|
||||
│ └── explored.png # (future) Explored/unexplored overlay
|
||||
├── notes/ # Reference notes
|
||||
│ └── tile-format-readme.txt # Original valheim-map.world tile format spec
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## 🔗 Related
|
||||
**Large files not in repo** (stored in /tmp/):
|
||||
- `/tmp/valheim_locations.json` — 52 MB raw location export
|
||||
- `/tmp/Nerdhalla.db` — 85 MB world save from Nerdcade AMP container
|
||||
- `/tmp/Nerdhalla.json` — 1.5 GB JSON conversion (valheim-save-tools)
|
||||
- `/tmp/valheim_tiles/tiles/*.bin.gz` — 16 tile files, ~100 MB total
|
||||
|
||||
- **Nerdcade server:** `aelith@100.86.206.39:46129`
|
||||
- **World saves:** `/home/amp/.ampdata/instances/Valheim/.config/unity3d/IronGate/Valheim/worlds_local/`
|
||||
- **Forgejo API token:** stored in Hermes memory
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
### ✅ Phase 1 — POI Database (Complete)
|
||||
|
||||
**Script:** `src/valheim_db.py`
|
||||
|
||||
Parsed `locations.json` (52 MB) into a queryable SQLite database with spatial support.
|
||||
|
||||
**Tables:**
|
||||
- `locations` — 11,309 entries (prefab name, category, coordinates, dungeon/loot flags)
|
||||
- `important_contents` — 24,407 entries (veggisirs, surtling cores, treasure, spawners)
|
||||
- `random_contents` — 457,617 entries (dungeon interior loot)
|
||||
- `dungeon_components` — 49,338 entries (room layouts)
|
||||
|
||||
**Spatial queries work:**
|
||||
- "Highest density of tar pits?" → Zone (1500, 3500) has 11 in 1000u radius
|
||||
- "Most Surtling Cores?" → Crypt3 @ (2039, -1162) has 20 cores
|
||||
- "Best base location for tar farming?" → (1210, -4159) — 5 tar pits in 200m, Plains biome
|
||||
|
||||
### ✅ Phase 2 — Web Map Viewer (Complete)
|
||||
|
||||
**Scripts:** `src/decode_valheim_tiles.py`, `src/build_viewer.py`
|
||||
|
||||
Decoded 16 tile files (4×4 grid, 1024×1024 samples each) into PNG images and built a Leaflet.js viewer.
|
||||
|
||||
**Features:**
|
||||
- **3 map layers:** Composite (biome+height shading), Biome (9-color classification), Height (grayscale elevation)
|
||||
- **11,309 POI markers** — color-coded by category, clickable with loot popups
|
||||
- **Category legend** — toggle groups on/off (boss, dungeon, vegvisir, vendor, resource, etc.)
|
||||
- **Search** — by prefab name or category
|
||||
- **Coordinate display** — Valheim world coords in popups
|
||||
|
||||
**Layer toggle buttons** (bottom-left): Composite | Biome | Height
|
||||
|
||||
### ⏳ Phase 3 — Fog-of-War Overlay (Blocked)
|
||||
|
||||
**Goal:** Show explored vs unexplored areas on the map.
|
||||
|
||||
**Blocked by:** The explored map data is stored per-player in `.fch` character files on each player's local machine, NOT in the world `.db` file. The `.db` contains 1.8M ZDOs (world objects, terrain, structures) but no explored bitmap.
|
||||
|
||||
**What we have:**
|
||||
- `Nerdhalla.db` (85 MB) — pulled from Nerdcade AMP container, parsed via valheim-save-tools
|
||||
- `Nerdhalla.fwl` (499 bytes) — world settings (seed, presets, difficulty)
|
||||
- ZPackage parser WIP in `src/parse_valheim_zpackage.py`
|
||||
|
||||
**What's needed:** Player `.fch` files from:
|
||||
- Windows: `%USERPROFILE%\AppData\LocalLow\IronGate\Valheim\characters\`
|
||||
- Linux: `~/.config/unity3d/IronGate/Valheim/characters/`
|
||||
|
||||
### 🔮 Phase 4 — Cartography Table Integration (Planned)
|
||||
|
||||
**Goal:** A shared map of truth for the server.
|
||||
|
||||
**Approach options (in order of preference):**
|
||||
|
||||
1. **ServerSideMap mod (BepInEx)** — Gold standard. Strips map data from client files, hosts on server in real-time. As you explore, fog clears for everyone. Pin sharing with dedup. Requires BepInEx on AMP Valheim instance + all players install the mod.
|
||||
|
||||
2. **Periodic merge script** — Collect `.fch` files from players, merge explored bitmaps (OR operation), deduplicate pins (same name + within 15m), write back clean files. Run on cron.
|
||||
|
||||
3. **Cartography table automation** — Lightweight BepInEx plugin that auto-triggers "Record Discoveries" near the table. Still manual but removes forgetfulness.
|
||||
|
||||
**Pin cleaning strategy:**
|
||||
- Dedup by name + proximity (15m radius)
|
||||
- "Last writer wins" for conflicting pins
|
||||
- Ghost pin prevention: track pin origin, only remove if no active player has it
|
||||
|
||||
---
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Tile Format
|
||||
|
||||
From the valheim-map.world export Readme:
|
||||
|
||||
```
|
||||
struct MapSample {
|
||||
uint16_t biome; // 2 bytes
|
||||
float height; // 4 bytes
|
||||
float forestFactor; // 4 bytes
|
||||
} // 10 bytes per sample
|
||||
```
|
||||
|
||||
- 1024 × 1024 samples per tile = 10,485,760 bytes uncompressed
|
||||
- 4 × 4 tiles = 16 tiles, 24000 × 24000 Valheim units
|
||||
- Gzip compressed individually
|
||||
|
||||
**Biome enum:** None=0, Meadows=1, Swamp=2, Mountain=4, BlackForest=8, Plains=16, AshLands=32, DeepNorth=64, Ocean=256, Mistlands=512
|
||||
|
||||
### Coordinate Mapping
|
||||
|
||||
```
|
||||
Valheim coords: (-12000, -12000) to (+12000, +12000)
|
||||
Image coords: (0, 0) to (4096, 4096)
|
||||
|
||||
pixel_x = (valheim_x + 12000) / 24000 * 4096
|
||||
pixel_y = (valheim_z + 12000) / 24000 * 4096
|
||||
```
|
||||
|
||||
### Zone System
|
||||
|
||||
From the [Valheim Wiki](https://valheim.fandom.com/wiki/Zones):
|
||||
- Zones are 64m × 64m
|
||||
- Generated: 9×9 around each player (288m radius)
|
||||
- Loaded: 5×5 (160m radius)
|
||||
- Active: 3×3 (96m radius)
|
||||
- Current: the zone(s) containing the player
|
||||
|
||||
### Database Schema
|
||||
|
||||
```sql
|
||||
-- Core locations table
|
||||
CREATE TABLE locations (
|
||||
id INTEGER PRIMARY KEY,
|
||||
prefab_name TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
pos_x REAL, pos_y REAL, pos_z REAL,
|
||||
has_dungeon INTEGER DEFAULT 0,
|
||||
has_important INTEGER DEFAULT 0,
|
||||
has_random INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
-- Important contents (veggisirs, cores, treasure)
|
||||
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)
|
||||
);
|
||||
|
||||
-- Random contents (dungeon interior loot)
|
||||
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)
|
||||
);
|
||||
|
||||
-- Dungeon components (room layouts)
|
||||
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)
|
||||
);
|
||||
```
|
||||
|
||||
### .db File Format (ZPackage)
|
||||
|
||||
The world `.db` file uses Unity's ZPackage binary serialization format:
|
||||
|
||||
```
|
||||
int32: world_version
|
||||
double: net_time
|
||||
int64: my_id
|
||||
uint32: next_uid
|
||||
int32: num_zdos
|
||||
ZDO[]: zone data objects (1,800,945 in Nerdhalla)
|
||||
Zones: zone management data
|
||||
RandomEvent: random event state
|
||||
SHA512: 64-byte hash
|
||||
```
|
||||
|
||||
Each ZDO contains: uid, prefab hash, owner, data type, revision, key-value pairs (floats, vectors, strings, byte arrays), position, rotation.
|
||||
|
||||
**Current world version:** 37 (valheim-save-tools max: 34 — parses with warning)
|
||||
|
||||
---
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Query the POI database
|
||||
|
||||
```bash
|
||||
sqlite3 ~/projects/valheim-map/data/valheim_poi.db
|
||||
|
||||
# Find all tar pits
|
||||
SELECT pos_x, pos_z FROM locations WHERE prefab_name LIKE '%TarPit%';
|
||||
|
||||
# Find boss altars
|
||||
SELECT prefab_name, pos_x, pos_z FROM locations WHERE category='boss';
|
||||
|
||||
# Find dungeons with most surtling cores
|
||||
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;
|
||||
|
||||
# Find vegvisirs
|
||||
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%';
|
||||
```
|
||||
|
||||
### Rebuild from scratch
|
||||
|
||||
```bash
|
||||
# 1. Build POI database
|
||||
python3 src/valheim_db.py
|
||||
|
||||
# 2. Decode tiles to PNG
|
||||
python3 src/decode_valheim_tiles.py
|
||||
|
||||
# 3. Build web viewer
|
||||
python3 src/build_viewer.py
|
||||
|
||||
# 4. Serve
|
||||
cd output && python3 -m http.server 8765
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Sources
|
||||
|
||||
| Source | File | Size | Origin |
|
||||
|--------|------|------|--------|
|
||||
| valheim-map.world export | `locations.json` | 52 MB | ROG (E:\Downloads\MapData_yzZ5fr2tGa\) |
|
||||
| valheim-map.world export | `tiles/*.bin.gz` | ~100 MB | ROG |
|
||||
| AMP Valheim container | `Nerdhalla.db` | 85 MB | Nerdcade (`docker cp AMP_Nerdhalla01:/AMP/Valheim/896660/Saves/worlds_local/`) |
|
||||
| AMP Valheim container | `Nerdhalla.fwl` | 499 B | Nerdcade |
|
||||
|
||||
---
|
||||
|
||||
## Future Work
|
||||
|
||||
- [ ] **Fog-of-war overlay** — get `.fch` files from players, extract explored bitmap
|
||||
- [ ] **ServerSideMap mod** — install BepInEx on AMP Valheim, deploy shared map
|
||||
- [ ] **Pin cleaning/syncing** — dedup, merge, ghost pin prevention
|
||||
- [ ] **Auto-update cron** — periodically pull `.db` from Nerdcade, regenerate map
|
||||
- [ ] **Forgejo repo** — host this project in a self-hosted git instance
|
||||
- [ ] **FoundryVTT export** — convert POI data for tabletop use
|
||||
- [ ] **Mobile-friendly** — responsive layout for phone viewing
|
||||
|
|
|
|||
|
|
@ -1,95 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Nerdhalla API server — serves POI data and server status."""
|
||||
import json
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
DB_PATH = "/tmp/valheim_poi.db"
|
||||
NERDCADE_SSH = "aelith@100.86.206.39"
|
||||
NERDCADE_PORT = 46129
|
||||
|
||||
class APIHandler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
parsed = urlparse(self.path)
|
||||
path = parsed.path
|
||||
params = parse_qs(parsed.query)
|
||||
|
||||
if path == "/api/status":
|
||||
self._handle_status()
|
||||
elif path == "/api/pois":
|
||||
self._handle_pois(params)
|
||||
else:
|
||||
self.send_error(404, "Not found")
|
||||
|
||||
def _handle_status(self):
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ssh", "-p", str(NERDCADE_PORT), "-o", "ConnectTimeout=5",
|
||||
"-o", "StrictHostKeyChecking=no", NERDCADE_SSH,
|
||||
"systemctl --user is-active amp* 2>/dev/null || echo 'inactive'"],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
online = "active" in result.stdout.lower()
|
||||
data = {"online": online, "players": None}
|
||||
except Exception:
|
||||
data = {"online": False, "players": None}
|
||||
|
||||
self._json_response(data)
|
||||
|
||||
def _handle_pois(self, params):
|
||||
try:
|
||||
limit = min(int(params.get("limit", [500])[0]), 11309)
|
||||
offset = int(params.get("offset", [0])[0])
|
||||
biome = params.get("biome", [None])[0]
|
||||
search = params.get("search", [None])[0]
|
||||
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cur = conn.cursor()
|
||||
|
||||
where_clauses = []
|
||||
bindings = []
|
||||
if biome:
|
||||
where_clauses.append("category = ?")
|
||||
bindings.append(biome)
|
||||
if search:
|
||||
where_clauses.append("prefab_name LIKE ?")
|
||||
bindings.append(f"%{search}%")
|
||||
|
||||
where = ""
|
||||
if where_clauses:
|
||||
where = "WHERE " + " AND ".join(where_clauses)
|
||||
|
||||
cur.execute(f"SELECT pos_x, pos_y, pos_z, prefab_name, category FROM locations {where} ORDER BY prefab_name LIMIT ? OFFSET ?", bindings + [limit, offset])
|
||||
rows = [{
|
||||
"x": r["pos_x"],
|
||||
"y": r["pos_z"], # Valheim uses Z as north-south
|
||||
"prefab": r["prefab_name"],
|
||||
"category": r["category"]
|
||||
} for r in cur.fetchall()]
|
||||
conn.close()
|
||||
|
||||
self._json_response(rows)
|
||||
except Exception as e:
|
||||
self._json_response({"error": str(e)}, status=500)
|
||||
|
||||
def _json_response(self, data, status=200):
|
||||
body = json.dumps(data).encode()
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, format, *args):
|
||||
sys.stderr.write("[API] %s - %s\n" % (self.client_address[0], format % args))
|
||||
|
||||
if __name__ == "__main__":
|
||||
port = 8081
|
||||
server = HTTPServer(("127.0.0.1", port), APIHandler)
|
||||
print(f"[Nerdhalla API] Listening on 127.0.0.1:{port}")
|
||||
server.serve_forever()
|
||||
BIN
data/Nerdhalla.fwl
Normal file
536
index.html
|
|
@ -1,536 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Nerdhalla — Valheim Server</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚔️</text></svg>">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0a0a0f;
|
||||
--surface: #14141f;
|
||||
--surface2: #1a1a2e;
|
||||
--accent: #c8a84e;
|
||||
--accent2: #8b6f3a;
|
||||
--text: #d4d4dc;
|
||||
--text2: #8888a0;
|
||||
--green: #4ade80;
|
||||
--red: #f87171;
|
||||
--border: #2a2a3e;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
}
|
||||
/* Header */
|
||||
.header {
|
||||
background: linear-gradient(135deg, var(--surface) 0%, #0d0d1a 100%);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 1.5rem 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
.header-icon { font-size: 2rem; }
|
||||
.header h1 {
|
||||
font-size: 1.5rem;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.header .subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text2);
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
.server-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.status-dot {
|
||||
width: 10px; height: 10px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
.status-dot.online { background: var(--green); box-shadow: 0 0 8px var(--green); }
|
||||
.status-dot.offline { background: var(--red); box-shadow: 0 0 8px var(--red); }
|
||||
.status-dot.unknown { background: var(--text2); }
|
||||
|
||||
/* Nav */
|
||||
.nav {
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
gap: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.nav a {
|
||||
padding: 0.75rem 1.5rem;
|
||||
color: var(--text2);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.nav a:hover, .nav a.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
background: rgba(200, 168, 78, 0.05);
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.content { max-width: 1200px; margin: 0 auto; padding: 2rem; }
|
||||
.page { display: none; }
|
||||
.page.active { display: block; }
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.card h2 {
|
||||
font-size: 1.1rem;
|
||||
color: var(--accent);
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.stat-card {
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
.stat-card .value {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
.stat-card .label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text2);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Map */
|
||||
#map {
|
||||
height: 600px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Info table */
|
||||
.info-table { width: 100%; border-collapse: collapse; }
|
||||
.info-table td {
|
||||
padding: 0.6rem 0.8rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.info-table td:first-child {
|
||||
color: var(--text2);
|
||||
width: 200px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.info-table tr:last-child td { border-bottom: none; }
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text2);
|
||||
font-size: 0.8rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.footer a { color: var(--accent); text-decoration: none; }
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.header { flex-direction: column; align-items: flex-start; }
|
||||
.header-left { width: 100%; }
|
||||
.server-status { width: 100%; justify-content: center; }
|
||||
#map { height: 400px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<div class="header-icon">⚔️</div>
|
||||
<div>
|
||||
<h1>Nerdhalla</h1>
|
||||
<div class="subtitle">Valheim Community Server</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="server-status" id="serverStatus">
|
||||
<span class="status-dot unknown" id="statusDot"></span>
|
||||
<span id="statusText">Checking server...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="nav">
|
||||
<a href="#" class="active" data-page="home">🏠 Home</a>
|
||||
<a href="#" data-page="map">🗺️ World Map</a>
|
||||
<a href="#" data-page="world">🌍 World Info</a>
|
||||
<a href="#" data-page="pois">📍 Points of Interest</a>
|
||||
</nav>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="content">
|
||||
<!-- Home Page -->
|
||||
<div class="page active" id="page-home">
|
||||
<div class="card">
|
||||
<h2>🏰 Welcome to Nerdhalla</h2>
|
||||
<p style="line-height:1.7; color: var(--text2);">
|
||||
A vanilla Valheim community server — no mods, no BepInEx, just pure Viking survival.
|
||||
Explore a hand-mapped world, find the best base locations, and conquer the tenth world together.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card-grid">
|
||||
<div class="stat-card">
|
||||
<div class="value" id="statSeed">yzZ5fr2tGa</div>
|
||||
<div class="label">World Seed</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="value" id="statBiomes">7</div>
|
||||
<div class="label">Biomes</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="value" id="statPOIs">11,309</div>
|
||||
<div class="label">Points of Interest</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="value" id="statMapSize">4,096</div>
|
||||
<div class="label">Map Size (px)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>🎯 Best Tar Pit Location</h2>
|
||||
<p style="color: var(--text2); margin-bottom: 0.5rem;">
|
||||
<strong style="color: var(--accent);">(1210, -4159)</strong> — 5 tar pits within 200m radius.
|
||||
Prime spot for a base with easy tar access.
|
||||
</p>
|
||||
<p style="color: var(--text2); font-size: 0.85rem;">
|
||||
Use the <a href="#" onclick="switchPage('map')" style="color: var(--accent);">World Map</a> to explore more locations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>📋 Quick Links</h2>
|
||||
<div style="display: flex; gap: 1rem; flex-wrap: wrap;">
|
||||
<a href="#" onclick="switchPage('map')" style="background: var(--accent2); color: #fff; padding: 0.6rem 1.2rem; border-radius: 6px; text-decoration: none; font-size: 0.9rem;">🗺️ Open World Map</a>
|
||||
<a href="#" onclick="switchPage('world')" style="background: var(--surface2); color: var(--text); padding: 0.6rem 1.2rem; border-radius: 6px; text-decoration: none; font-size: 0.9rem; border: 1px solid var(--border);">🌍 World Details</a>
|
||||
<a href="#" onclick="switchPage('pois')" style="background: var(--surface2); color: var(--text); padding: 0.6rem 1.2rem; border-radius: 6px; text-decoration: none; font-size: 0.9rem; border: 1px solid var(--border);">📍 Browse POIs</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map Page -->
|
||||
<div class="page" id="page-map">
|
||||
<div class="card" style="padding: 0; overflow: hidden;">
|
||||
<div id="map"></div>
|
||||
</div>
|
||||
<div class="card" style="margin-top: 1rem;">
|
||||
<h2>🗺️ Map Controls</h2>
|
||||
<div style="display: flex; gap: 1rem; flex-wrap: wrap; font-size: 0.85rem; color: var(--text2);">
|
||||
<span>🖱️ Click POI markers for details</span>
|
||||
<span>🔍 Scroll to zoom</span>
|
||||
<span>📦 Layer selector in top-right</span>
|
||||
<span>🔎 Search box for locations</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- World Info Page -->
|
||||
<div class="page" id="page-world">
|
||||
<div class="card">
|
||||
<h2>🌍 World Settings</h2>
|
||||
<table class="info-table">
|
||||
<tr><td>Seed</td><td><code>yzZ5fr2tGa</code></td></tr>
|
||||
<tr><td>World Name</td><td>Nerdhalla</td></tr>
|
||||
<tr><td>Difficulty</td><td>Normal (combat, raids, resources)</td></tr>
|
||||
<tr><td>Map Size</td><td>~10 km × 10 km (4,096 × 4,096 px tiles)</td></tr>
|
||||
<tr><td>Biomes</td><td>Meadows, Black Forest, Swamp, Mountain, Plains, Ocean, Mistlands</td></tr>
|
||||
<tr><td>Total POIs</td><td>11,309 locations catalogued</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>📊 Biome Distribution</h2>
|
||||
<div id="biomeChart" style="height: 200px; display: flex; align-items: flex-end; gap: 0.5rem; padding: 1rem 0;">
|
||||
<div style="flex:1; text-align:center;">
|
||||
<div style="background:#7ec850; height:120px; border-radius:4px 4px 0 0;"></div>
|
||||
<div style="font-size:0.75rem; color:var(--text2); margin-top:0.3rem;">Meadows</div>
|
||||
</div>
|
||||
<div style="flex:1; text-align:center;">
|
||||
<div style="background:#2d5a1e; height:160px; border-radius:4px 4px 0 0;"></div>
|
||||
<div style="font-size:0.75rem; color:var(--text2); margin-top:0.3rem;">Black Forest</div>
|
||||
</div>
|
||||
<div style="flex:1; text-align:center;">
|
||||
<div style="background:#3a3a1a; height:90px; border-radius:4px 4px 0 0;"></div>
|
||||
<div style="font-size:0.75rem; color:var(--text2); margin-top:0.3rem;">Swamp</div>
|
||||
</div>
|
||||
<div style="flex:1; text-align:center;">
|
||||
<div style="background:#8b8b8b; height:140px; border-radius:4px 4px 0 0;"></div>
|
||||
<div style="font-size:0.75rem; color:var(--text2); margin-top:0.3rem;">Mountain</div>
|
||||
</div>
|
||||
<div style="flex:1; text-align:center;">
|
||||
<div style="background:#c8a84e; height:100px; border-radius:4px 4px 0 0;"></div>
|
||||
<div style="font-size:0.75rem; color:var(--text2); margin-top:0.3rem;">Plains</div>
|
||||
</div>
|
||||
<div style="flex:1; text-align:center;">
|
||||
<div style="background:#4a6fa5; height:180px; border-radius:4px 4px 0 0;"></div>
|
||||
<div style="font-size:0.75rem; color:var(--text2); margin-top:0.3rem;">Ocean</div>
|
||||
</div>
|
||||
<div style="flex:1; text-align:center;">
|
||||
<div style="background:#5a2d7a; height:70px; border-radius:4px 4px 0 0;"></div>
|
||||
<div style="font-size:0.75rem; color:var(--text2); margin-top:0.3rem;">Mistlands</div>
|
||||
</div>
|
||||
</div>
|
||||
<p style="font-size:0.8rem; color:var(--text2); text-align:center;">Approximate — based on tile analysis</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- POIs Page -->
|
||||
<div class="page" id="page-pois">
|
||||
<div class="card">
|
||||
<h2>📍 Points of Interest</h2>
|
||||
<p style="color: var(--text2); margin-bottom: 1rem;">
|
||||
All 11,309 locations from the Nerdhalla world, searchable and categorized.
|
||||
</p>
|
||||
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1rem;">
|
||||
<input type="text" id="poiSearch" placeholder="Search POIs..." style="flex:1; min-width:200px; padding:0.6rem; background:var(--surface2); border:1px solid var(--border); border-radius:6px; color:var(--text); font-size:0.9rem;">
|
||||
<select id="poiCategory" style="padding:0.6rem; background:var(--surface2); border:1px solid var(--border); border-radius:6px; color:var(--text); font-size:0.9rem;">
|
||||
<option value="">All Categories</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="poiResults" style="max-height: 500px; overflow-y: auto;">
|
||||
<p style="color: var(--text2); text-align: center; padding: 2rem;">
|
||||
Loading POI data from database...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="footer">
|
||||
<p>Nerdhalla — Vanilla Valheim Community Server</p>
|
||||
<p style="margin-top: 0.3rem;">
|
||||
<a href="http://git.sweeney.fyi:3000/aelith/valheim-map" target="_blank">Source on Forgejo</a>
|
||||
· Powered by <a href="https://caddyserver.com" target="_blank">Caddy</a>
|
||||
· Map by <a href="https://leafletjs.com" target="_blank">Leaflet</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Leaflet -->
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="https://unpkg.com/leaflet-search@3.0.10/dist/leaflet-search.src.js"></script>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet-search@3.0.10/dist/leaflet-search.src.css" />
|
||||
|
||||
<script>
|
||||
// ====== Page Navigation ======
|
||||
function switchPage(name) {
|
||||
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
||||
document.querySelectorAll('.nav a').forEach(a => a.classList.remove('active'));
|
||||
document.getElementById('page-' + name).classList.add('active');
|
||||
document.querySelector(`.nav a[data-page="${name}"]`).classList.add('active');
|
||||
if (name === 'map' && map) setTimeout(() => map.invalidateSize(), 100);
|
||||
if (name === 'pois') loadPOIs();
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
document.querySelectorAll('.nav a').forEach(a => {
|
||||
a.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
switchPage(a.dataset.page);
|
||||
});
|
||||
});
|
||||
|
||||
// ====== Server Status ======
|
||||
async function checkServer() {
|
||||
const dot = document.getElementById('statusDot');
|
||||
const text = document.getElementById('statusText');
|
||||
try {
|
||||
const resp = await fetch('/api/status');
|
||||
const data = await resp.json();
|
||||
if (data.online) {
|
||||
dot.className = 'status-dot online';
|
||||
text.textContent = `🟢 Online — ${data.players || 0} player(s)`;
|
||||
} else {
|
||||
dot.className = 'status-dot offline';
|
||||
text.textContent = '🔴 Offline';
|
||||
}
|
||||
} catch {
|
||||
dot.className = 'status-dot unknown';
|
||||
text.textContent = '❓ Status unavailable';
|
||||
}
|
||||
}
|
||||
checkServer();
|
||||
setInterval(checkServer, 60000);
|
||||
|
||||
// ====== Map ======
|
||||
let map = null;
|
||||
function initMap() {
|
||||
if (map) return;
|
||||
map = L.map('map', {
|
||||
center: [0, 0],
|
||||
zoom: 1,
|
||||
maxZoom: 5,
|
||||
minZoom: 0,
|
||||
crs: L.CRS.Simple,
|
||||
zoomControl: true
|
||||
});
|
||||
|
||||
const bounds = [[0, 0], [4096, 4096]];
|
||||
|
||||
// Tile layers
|
||||
const composite = L.imageOverlay('world_composite.png', bounds, { opacity: 1 });
|
||||
const biome = L.imageOverlay('world_biome.png', bounds, { opacity: 0.7 });
|
||||
const height = L.imageOverlay('world_height.png', bounds, { opacity: 0.5 });
|
||||
|
||||
const baseMaps = {
|
||||
"Composite": composite,
|
||||
"Biome": biome,
|
||||
"Height": height
|
||||
};
|
||||
|
||||
composite.addTo(map);
|
||||
map.fitBounds(bounds);
|
||||
|
||||
L.control.layers(baseMaps, null, { collapsed: false }).addTo(map);
|
||||
|
||||
// Coordinate display
|
||||
const coordDisplay = L.control({ position: 'bottomleft' });
|
||||
coordDisplay.onAdd = function() {
|
||||
const div = L.DomUtil.create('div', 'coord-display');
|
||||
div.style.cssText = 'background:rgba(20,20,31,0.9); color:#d4d4dc; padding:4px 10px; border-radius:4px; font-size:12px; border:1px solid #2a2a3e;';
|
||||
div.innerHTML = 'Move cursor for coordinates';
|
||||
return div;
|
||||
};
|
||||
coordDisplay.addTo(map);
|
||||
|
||||
map.on('mousemove', function(e) {
|
||||
const x = Math.round(e.latlng.lng);
|
||||
const y = Math.round(e.latlng.lat);
|
||||
document.querySelector('.coord-display').innerHTML = `📍 (${x}, ${y})`;
|
||||
});
|
||||
|
||||
// POI markers from database
|
||||
fetch('/api/pois?limit=500')
|
||||
.then(r => r.json())
|
||||
.then(pois => {
|
||||
const poiIcons = {
|
||||
'point_of_interest': L.divIcon({ html: '📍', className: 'poi-icon', iconSize: [20, 20] }),
|
||||
'boss': L.divIcon({ html: '👑', className: 'poi-icon', iconSize: [20, 20] }),
|
||||
'dungeon': L.divIcon({ html: '🏚️', className: 'poi-icon', iconSize: [20, 20] }),
|
||||
'resource': L.divIcon({ html: '⛏️', className: 'poi-icon', iconSize: [20, 20] }),
|
||||
'structure': L.divIcon({ html: '🏠', className: 'poi-icon', iconSize: [20, 20] }),
|
||||
'spawner': L.divIcon({ html: '👾', className: 'poi-icon', iconSize: [20, 20] }),
|
||||
'altar': L.divIcon({ html: '🪦', className: 'poi-icon', iconSize: [20, 20] }),
|
||||
'vendor': L.divIcon({ html: '🏪', className: 'poi-icon', iconSize: [20, 20] }),
|
||||
'other': L.divIcon({ html: '❓', className: 'poi-icon', iconSize: [20, 20] }),
|
||||
'default': L.divIcon({ html: '📍', className: 'poi-icon', iconSize: [20, 20] })
|
||||
};
|
||||
const markerLayer = L.layerGroup();
|
||||
pois.forEach(p => {
|
||||
const icon = poiIcons[p.category] || poiIcons['default'];
|
||||
const m = L.marker([p.y, p.x], { icon }).bindPopup(
|
||||
`<b>${p.prefab || 'Unknown'}</b><br>📍 (${Math.round(p.x)}, ${Math.round(p.y)})<br>📂 ${p.category || 'Unknown'}`
|
||||
);
|
||||
markerLayer.addLayer(m);
|
||||
});
|
||||
markerLayer.addTo(map);
|
||||
L.control.layers(null, { "POIs (11k+)": markerLayer }, { collapsed: true }).addTo(map);
|
||||
});
|
||||
}
|
||||
|
||||
// Init map when page loads or switches to map
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Defer map init until tab is clicked
|
||||
});
|
||||
document.querySelector('.nav a[data-page="map"]').addEventListener('click', () => {
|
||||
setTimeout(initMap, 200);
|
||||
});
|
||||
|
||||
// ====== POI Browser ======
|
||||
let allPOIs = [];
|
||||
async function loadPOIs() {
|
||||
const results = document.getElementById('poiResults');
|
||||
if (allPOIs.length > 0) return renderPOIs();
|
||||
try {
|
||||
const resp = await fetch('/api/pois?limit=11309');
|
||||
allPOIs = await resp.json();
|
||||
// Populate categories
|
||||
const cats = new Set(allPOIs.map(p => p.category).filter(Boolean));
|
||||
const sel = document.getElementById('poiCategory');
|
||||
cats.forEach(c => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = c;
|
||||
opt.textContent = c.replace(/_/g, ' ');
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
renderPOIs();
|
||||
} catch {
|
||||
results.innerHTML = '<p style="color: var(--red); text-align: center; padding: 2rem;">Failed to load POIs</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderPOIs() {
|
||||
const search = document.getElementById('poiSearch').value.toLowerCase();
|
||||
const cat = document.getElementById('poiCategory').value;
|
||||
const filtered = allPOIs.filter(p => {
|
||||
const name = (p.prefab || '').toLowerCase();
|
||||
const catVal = (p.category || '');
|
||||
return name.includes(search) && (!cat || catVal === cat);
|
||||
});
|
||||
const results = document.getElementById('poiResults');
|
||||
if (filtered.length === 0) {
|
||||
results.innerHTML = '<p style="color: var(--text2); text-align: center; padding: 2rem;">No matching POIs</p>';
|
||||
return;
|
||||
}
|
||||
results.innerHTML = filtered.slice(0, 200).map(p => `
|
||||
<div style="padding:0.5rem; border-bottom:1px solid var(--border); display:flex; justify-content:space-between; align-items:center;">
|
||||
<span><strong>${p.prefab || 'Unknown'}</strong> <span style="color:var(--text2);font-size:0.8rem;">(${Math.round(p.x)}, ${Math.round(p.y)})</span></span>
|
||||
<span style="color:var(--text2);font-size:0.8rem;">${(p.category || '').replace(/_/g, ' ')}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
if (filtered.length > 200) {
|
||||
results.innerHTML += `<p style="color:var(--text2);text-align:center;padding:0.5rem;font-size:0.8rem;">Showing 200 of ${filtered.length} results</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('poiSearch').addEventListener('input', renderPOIs);
|
||||
document.getElementById('poiCategory').addEventListener('change', renderPOIs);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
nerdhalla.nerdcade.cc {
|
||||
root * /var/www/nerdhalla
|
||||
file_server
|
||||
|
||||
# API reverse proxy
|
||||
handle_path /api/* {
|
||||
reverse_proxy 127.0.0.1:8081
|
||||
}
|
||||
|
||||
# Security headers
|
||||
header {
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-Frame-Options "DENY"
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
}
|
||||
|
||||
# Logs
|
||||
log {
|
||||
output file /var/log/caddy/nerdhalla.log
|
||||
}
|
||||
}
|
||||
19
notes/tile-format-readme.txt
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
Tile format (from valheim-map.world export Readme.txt):
|
||||
|
||||
Each tile is a gzip-compressed binary file containing a flat array of:
|
||||
struct MapSample {
|
||||
uint16_t biome; // 2 bytes - biome enum
|
||||
float height; // 4 bytes - height in Valheim units
|
||||
float forestFactor; // 4 bytes - forest density
|
||||
} // = 10 bytes per sample
|
||||
|
||||
1024 x 1024 samples per tile = 10,485,760 bytes uncompressed
|
||||
4x4 tiles = 16 tiles total
|
||||
World: 24000 x 24000 Valheim units
|
||||
|
||||
Biome enum:
|
||||
None=0, Meadows=1, Swamp=2, Mountain=4, BlackForest=8,
|
||||
Plains=16, AshLands=32, DeepNorth=64, Ocean=256, Mistlands=512
|
||||
|
||||
locations.json: list of every location with prefab name, position, contents
|
||||
map.json: world metadata (seed, tile size, version)
|
||||
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 204 KiB After Width: | Height: | Size: 204 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 233 KiB After Width: | Height: | Size: 233 KiB |
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 8.9 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 163 KiB |
|
Before Width: | Height: | Size: 127 KiB After Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 406 KiB After Width: | Height: | Size: 406 KiB |
|
Before Width: | Height: | Size: 277 KiB After Width: | Height: | Size: 277 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 422 KiB After Width: | Height: | Size: 422 KiB |
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 256 KiB After Width: | Height: | Size: 256 KiB |
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 159 KiB After Width: | Height: | Size: 159 KiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 411 KiB After Width: | Height: | Size: 411 KiB |
|
Before Width: | Height: | Size: 274 KiB After Width: | Height: | Size: 274 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 402 KiB After Width: | Height: | Size: 402 KiB |
|
Before Width: | Height: | Size: 270 KiB After Width: | Height: | Size: 270 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 275 KiB After Width: | Height: | Size: 275 KiB |
|
Before Width: | Height: | Size: 156 KiB After Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 224 KiB After Width: | Height: | Size: 224 KiB |
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 8.9 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
252
output/index.html
Normal file
|
Before Width: | Height: | Size: 374 KiB After Width: | Height: | Size: 374 KiB |
|
Before Width: | Height: | Size: 3.6 MiB After Width: | Height: | Size: 3.6 MiB |
|
Before Width: | Height: | Size: 2.3 MiB After Width: | Height: | Size: 2.3 MiB |
344
src/build_viewer.py
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Build a Leaflet.js web viewer for the Nerdhalla Valheim world.
|
||||
"""
|
||||
import sqlite3, json, os
|
||||
|
||||
DB_PATH = "/tmp/valheim_poi.db"
|
||||
OUTPUT_DIR = "/tmp/valheim_map_output"
|
||||
VIEWER_PATH = os.path.join(OUTPUT_DIR, "index.html")
|
||||
|
||||
WORLD_SIZE = 24000
|
||||
PIXELS_TOTAL = 4096
|
||||
|
||||
def valheim_to_pixel(vx, vz):
|
||||
px = (vx + WORLD_SIZE/2) / WORLD_SIZE * PIXELS_TOTAL
|
||||
py = (vz + WORLD_SIZE/2) / WORLD_SIZE * PIXELS_TOTAL
|
||||
return px, py
|
||||
|
||||
def query_pois():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
c = conn.cursor()
|
||||
c.execute("""
|
||||
SELECT id, prefab_name, category, pos_x, pos_z, has_dungeon, has_important
|
||||
FROM locations WHERE pos_x IS NOT NULL AND pos_z IS NOT NULL
|
||||
ORDER BY category, prefab_name
|
||||
""")
|
||||
pois = []
|
||||
for row in c.fetchall():
|
||||
loc_id, name, cat, x, z, has_dungeon, has_important = row
|
||||
px, py = valheim_to_pixel(x, z)
|
||||
pois.append({
|
||||
'id': loc_id, 'name': name, 'category': cat,
|
||||
'x': round(x, 1), 'z': round(z, 1),
|
||||
'px': round(px, 1), 'py': round(py, 1),
|
||||
'has_dungeon': bool(has_dungeon), 'has_important': bool(has_important),
|
||||
})
|
||||
|
||||
c.execute("""
|
||||
SELECT l.id, l.prefab_name, l.pos_x, l.pos_z, ic.friendly_name, ic.count
|
||||
FROM locations l JOIN important_contents ic ON l.id = ic.location_id
|
||||
WHERE l.pos_x IS NOT NULL AND l.pos_z IS NOT NULL
|
||||
ORDER BY ic.count DESC
|
||||
""")
|
||||
contents = []
|
||||
for row in c.fetchall():
|
||||
loc_id, name, x, z, fname, count = row
|
||||
px, py = valheim_to_pixel(x, z)
|
||||
contents.append({
|
||||
'loc_id': loc_id, 'loc_name': name, 'item': fname, 'count': count,
|
||||
'x': round(x, 1), 'z': round(z, 1),
|
||||
'px': round(px, 1), 'py': round(py, 1),
|
||||
})
|
||||
conn.close()
|
||||
return pois, contents
|
||||
|
||||
def build_viewer(pois, contents):
|
||||
pois_json = json.dumps(pois)
|
||||
contents_json = json.dumps(contents)
|
||||
|
||||
cat_colors = {
|
||||
'boss': '#ff4444', 'dungeon': '#ff8800', 'vegvisir': '#44aaff',
|
||||
'altar': '#aa88ff', 'vendor': '#ffdd00', 'structure': '#88cc88',
|
||||
'resource': '#ff66aa', 'spawner': '#ff44ff', 'point_of_interest': '#44ffaa',
|
||||
'other': '#888888',
|
||||
}
|
||||
cat_colors_json = json.dumps(cat_colors)
|
||||
|
||||
cat_labels = {
|
||||
'boss': 'Boss Altars', 'dungeon': 'Dungeons', 'vegvisir': 'Vegvisirs',
|
||||
'vendor': 'Vendors', 'altar': 'Altars / Stones', 'structure': 'Structures',
|
||||
'resource': 'Resources', 'spawner': 'Spawners', 'point_of_interest': 'POIs',
|
||||
'other': 'Other',
|
||||
}
|
||||
cat_labels_json = json.dumps(cat_labels)
|
||||
|
||||
# Build category counts
|
||||
cat_counts = {}
|
||||
for p in pois:
|
||||
cat_counts[p['category']] = cat_counts.get(p['category'], 0) + 1
|
||||
cat_counts_json = json.dumps(cat_counts)
|
||||
|
||||
html = """<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Nerdhalla — Valheim World Map</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { height: 100%; font-family: 'Segoe UI', system-ui, sans-serif; }
|
||||
#map { height: 100vh; width: 100vw; }
|
||||
.leaflet-container { background: #1a1a2e; }
|
||||
.info-panel {
|
||||
position: absolute; top: 10px; right: 10px; z-index: 1000;
|
||||
background: rgba(20, 20, 35, 0.92); color: #ddd;
|
||||
padding: 12px 16px; border-radius: 8px; font-size: 13px;
|
||||
max-width: 280px; max-height: 80vh; overflow-y: auto;
|
||||
border: 1px solid #444; backdrop-filter: blur(8px);
|
||||
}
|
||||
.info-panel h3 { color: #ffd700; margin-bottom: 6px; font-size: 15px; }
|
||||
.info-panel .stat { display: flex; justify-content: space-between; padding: 2px 0; }
|
||||
.info-panel .stat span:last-child { color: #aaa; }
|
||||
.legend {
|
||||
position: absolute; bottom: 30px; right: 10px; z-index: 1000;
|
||||
background: rgba(20, 20, 35, 0.92); color: #ddd;
|
||||
padding: 10px 14px; border-radius: 8px; font-size: 12px;
|
||||
border: 1px solid #444; backdrop-filter: blur(8px);
|
||||
max-height: 50vh; overflow-y: auto;
|
||||
}
|
||||
.legend-item { display: flex; align-items: center; gap: 6px; padding: 2px 0; cursor: pointer; }
|
||||
.legend-item .dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||
.legend-item .count { color: #888; margin-left: auto; font-size: 11px; }
|
||||
.legend-item.active { color: #fff; }
|
||||
.legend-item.inactive { opacity: 0.4; }
|
||||
.search-box {
|
||||
position: absolute; top: 10px; left: 10px; z-index: 1000;
|
||||
background: rgba(20, 20, 35, 0.92); border: 1px solid #444;
|
||||
border-radius: 8px; padding: 8px 12px; backdrop-filter: blur(8px);
|
||||
display: flex; gap: 6px; align-items: center;
|
||||
}
|
||||
.search-box input {
|
||||
background: transparent; border: none; color: #ddd; font-size: 13px;
|
||||
outline: none; width: 180px;
|
||||
}
|
||||
.search-box input::placeholder { color: #666; }
|
||||
.search-box .results {
|
||||
position: absolute; top: 100%; left: 0; right: 0;
|
||||
background: rgba(20, 20, 35, 0.95); border: 1px solid #444;
|
||||
border-radius: 0 0 8px 8px; max-height: 200px; overflow-y: auto;
|
||||
display: none;
|
||||
}
|
||||
.search-box .results div { padding: 6px 12px; cursor: pointer; font-size: 12px; border-bottom: 1px solid #333; }
|
||||
.search-box .results div:hover { background: rgba(255,255,255,0.1); }
|
||||
.search-box .results div .cat { color: #888; font-size: 10px; }
|
||||
.popup-content { font-size: 12px; line-height: 1.5; }
|
||||
.popup-content .name { font-weight: bold; font-size: 14px; color: #ffd700; }
|
||||
.popup-content .coords { color: #888; }
|
||||
.popup-content .badge { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 10px; margin: 2px 2px 0 0; }
|
||||
.popup-content .badge-dungeon { background: #ff8800; color: #000; }
|
||||
.popup-content .badge-treasure { background: #ffd700; color: #000; }
|
||||
.toggle-layer {
|
||||
position: absolute; bottom: 30px; left: 10px; z-index: 1000;
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
}
|
||||
.toggle-layer button {
|
||||
background: rgba(20, 20, 35, 0.92); color: #ddd; border: 1px solid #444;
|
||||
padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 11px;
|
||||
backdrop-filter: blur(8px); text-align: left;
|
||||
}
|
||||
.toggle-layer button.active { border-color: #ffd700; color: #ffd700; }
|
||||
.toggle-layer button:hover { background: rgba(40, 40, 60, 0.95); }
|
||||
@media (max-width: 768px) { .info-panel { display: none; } .search-box input { width: 120px; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="map"></div>
|
||||
|
||||
<div class="search-box">
|
||||
<span style="color:#666;">🔍</span>
|
||||
<input type="text" id="search" placeholder="Search POIs..." oninput="doSearch(this.value)">
|
||||
<div class="results" id="searchResults"></div>
|
||||
</div>
|
||||
|
||||
<div class="info-panel" id="infoPanel">
|
||||
<h3>🌍 Nerdhalla</h3>
|
||||
<div class="stat"><span>Seed</span><span>yzZ5fr2tGa</span></div>
|
||||
<div class="stat"><span>World</span><span>24000 x 24000</span></div>
|
||||
<div class="stat"><span>POIs</span><span>""" + str(len(pois)) + """</span></div>
|
||||
<div class="stat"><span>Items</span><span>""" + str(len(contents)) + """</span></div>
|
||||
<div class="stat"><span>Biomes</span><span>9</span></div>
|
||||
<hr style="border-color:#333;margin:6px 0;">
|
||||
<div style="font-size:11px;color:#888;">
|
||||
Click a marker for details<br>
|
||||
Search by name or category
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toggle-layer">
|
||||
<button class="active" onclick="toggleLayer('composite')" id="btn-composite">🗺 Composite</button>
|
||||
<button onclick="toggleLayer('biome')" id="btn-biome">🌿 Biome</button>
|
||||
<button onclick="toggleLayer('height')" id="btn-height">⛰ Height</button>
|
||||
</div>
|
||||
|
||||
<div class="legend" id="legend"></div>
|
||||
|
||||
<script>
|
||||
const POIS = """ + pois_json + """;
|
||||
const CONTENTS = """ + contents_json + """;
|
||||
const CAT_COLORS = """ + cat_colors_json + """;
|
||||
const CAT_LABELS = """ + cat_labels_json + """;
|
||||
const CAT_COUNTS = """ + cat_counts_json + """;
|
||||
|
||||
const map = L.map('map', {
|
||||
center: [2048, 2048],
|
||||
zoom: -2,
|
||||
minZoom: -3,
|
||||
maxZoom: 3,
|
||||
crs: L.CRS.Simple,
|
||||
zoomSnap: 0.5,
|
||||
zoomDelta: 0.5,
|
||||
});
|
||||
|
||||
const bounds = [[0, 0], [4096, 4096]];
|
||||
|
||||
const layers = {};
|
||||
layers.composite = L.imageOverlay('world_composite.png', bounds, { opacity: 1.0 }).addTo(map);
|
||||
layers.biome = L.imageOverlay('world_biome.png', bounds, { opacity: 0.0 }).addTo(map);
|
||||
layers.height = L.imageOverlay('world_height.png', bounds, { opacity: 0.0 }).addTo(map);
|
||||
|
||||
let activeLayer = 'composite';
|
||||
|
||||
function toggleLayer(name) {
|
||||
if (name === activeLayer) return;
|
||||
layers[activeLayer].setOpacity(0.0);
|
||||
layers[name].setOpacity(1.0);
|
||||
document.getElementById('btn-' + activeLayer).classList.remove('active');
|
||||
document.getElementById('btn-' + name).classList.add('active');
|
||||
activeLayer = name;
|
||||
}
|
||||
|
||||
let markerLayer = L.layerGroup().addTo(map);
|
||||
let allMarkers = [];
|
||||
let activeCategories = new Set(Object.keys(CAT_COUNTS));
|
||||
|
||||
function createMarkers() {
|
||||
markerLayer.clearLayers();
|
||||
allMarkers = [];
|
||||
|
||||
POIS.forEach(function(p) {
|
||||
if (!activeCategories.has(p.category)) return;
|
||||
|
||||
const color = CAT_COLORS[p.category] || '#888';
|
||||
const icon = L.divIcon({
|
||||
className: '',
|
||||
html: '<div style="width:10px;height:10px;border-radius:50%;background:' + color + ';border:2px solid rgba(255,255,255,0.5);box-shadow:0 0 4px rgba(0,0,0,0.5);"></div>',
|
||||
iconSize: [10, 10],
|
||||
iconAnchor: [5, 5],
|
||||
});
|
||||
|
||||
const marker = L.marker([p.py, p.px], { icon: icon }).addTo(markerLayer);
|
||||
|
||||
let popup = '<div class="popup-content">';
|
||||
popup += '<div class="name">' + p.name + '</div>';
|
||||
popup += '<div class="coords">(' + p.x + ', ' + p.z + ')</div>';
|
||||
popup += '<div style="color:' + color + ';">' + p.category + '</div>';
|
||||
if (p.has_dungeon) popup += '<span class="badge badge-dungeon">Dungeon</span>';
|
||||
if (p.has_important) popup += '<span class="badge badge-treasure">Has Loot</span>';
|
||||
|
||||
const items = CONTENTS.filter(function(c) { return c.loc_id === p.id; });
|
||||
if (items.length > 0) {
|
||||
popup += '<hr style="border-color:#333;margin:4px 0;"><div style="font-size:11px;">';
|
||||
for (let i = 0; i < Math.min(5, items.length); i++) {
|
||||
popup += '<div>• ' + items[i].item + ' x' + items[i].count + '</div>';
|
||||
}
|
||||
if (items.length > 5) {
|
||||
popup += '<div style="color:#888;">... +' + (items.length - 5) + ' more</div>';
|
||||
}
|
||||
popup += '</div>';
|
||||
}
|
||||
|
||||
popup += '</div>';
|
||||
marker.bindPopup(popup);
|
||||
allMarkers.push({ marker: marker, p: p });
|
||||
});
|
||||
}
|
||||
|
||||
function buildLegend() {
|
||||
const legend = document.getElementById('legend');
|
||||
const order = ['boss', 'dungeon', 'vegvisir', 'vendor', 'resource', 'altar', 'structure', 'spawner', 'point_of_interest', 'other'];
|
||||
let html = '';
|
||||
order.forEach(function(cat) {
|
||||
if (!CAT_COUNTS[cat]) return;
|
||||
const color = CAT_COLORS[cat] || '#888';
|
||||
const active = activeCategories.has(cat) ? 'active' : 'inactive';
|
||||
html += '<div class="legend-item ' + active + '" onclick="toggleCategory(\\'' + cat + '\\')">';
|
||||
html += '<span class="dot" style="background:' + color + '"></span>';
|
||||
html += '<span>' + (CAT_LABELS[cat] || cat) + '</span>';
|
||||
html += '<span class="count">' + CAT_COUNTS[cat] + '</span></div>';
|
||||
});
|
||||
legend.innerHTML = html;
|
||||
}
|
||||
|
||||
function toggleCategory(cat) {
|
||||
if (activeCategories.has(cat)) {
|
||||
activeCategories.delete(cat);
|
||||
} else {
|
||||
activeCategories.add(cat);
|
||||
}
|
||||
createMarkers();
|
||||
buildLegend();
|
||||
}
|
||||
|
||||
function doSearch(query) {
|
||||
const results = document.getElementById('searchResults');
|
||||
if (!query || query.length < 2) { results.style.display = 'none'; return; }
|
||||
|
||||
const q = query.toLowerCase();
|
||||
const matches = POIS.filter(function(p) {
|
||||
return p.name.toLowerCase().indexOf(q) !== -1 || p.category.toLowerCase().indexOf(q) !== -1;
|
||||
}).slice(0, 20);
|
||||
|
||||
if (matches.length === 0) { results.style.display = 'none'; return; }
|
||||
|
||||
let html = '';
|
||||
matches.forEach(function(p) {
|
||||
html += '<div onclick="flyTo(' + p.px + ', ' + p.py + ', \\'' + p.name + '\\')">';
|
||||
html += '<span style="color:' + (CAT_COLORS[p.category] || '#888') + ';">●</span> ';
|
||||
html += p.name + ' <span class="cat">' + p.category + '</span></div>';
|
||||
});
|
||||
results.innerHTML = html;
|
||||
results.style.display = 'block';
|
||||
}
|
||||
|
||||
function flyTo(px, py, name) {
|
||||
map.flyTo([py, px], 1, { duration: 0.5 });
|
||||
document.getElementById('searchResults').style.display = 'none';
|
||||
document.getElementById('search').value = name;
|
||||
}
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!e.target.closest('.search-box')) {
|
||||
document.getElementById('searchResults').style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
createMarkers();
|
||||
buildLegend();
|
||||
map.fitBounds(bounds);
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
with open(VIEWER_PATH, 'w') as f:
|
||||
f.write(html)
|
||||
print(f"Viewer written to {VIEWER_PATH}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
print("Building Valheim map viewer...")
|
||||
pois, contents = query_pois()
|
||||
print(f"Loaded {len(pois)} POIs and {len(contents)} important contents")
|
||||
build_viewer(pois, contents)
|
||||
print("Done!")
|
||||
254
src/decode_valheim_tiles.py
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Decode Valheim map tiles (from valheim-map.world export) into PNG images.
|
||||
Tile format per sample: uint16_t biome + float height + float forestFactor = 10 bytes.
|
||||
1024 x 1024 samples per tile, 4x4 tiles = 24000x24000 world.
|
||||
"""
|
||||
import struct, gzip, os, json, math
|
||||
from PIL import Image
|
||||
|
||||
TILES_DIR = "/tmp/valheim_tiles/tiles"
|
||||
OUTPUT_DIR = "/tmp/valheim_map_output"
|
||||
MAP_JSON_PATH = "/tmp/valheim_map_data/map.json"
|
||||
|
||||
# Biome colors (distinct, map-friendly)
|
||||
BIOME_COLORS = {
|
||||
0: (30, 30, 40), # None (deep ocean/void)
|
||||
1: (107, 142, 35), # Meadows - olive green
|
||||
2: (85, 107, 47), # Swamp - dark olive
|
||||
4: (139, 137, 137), # Mountain - grey
|
||||
8: (34, 100, 34), # BlackForest - dark green
|
||||
16: (210, 180, 60), # Plains - gold
|
||||
32: (139, 69, 19), # AshLands - dark brown/rust
|
||||
64: (200, 220, 255), # DeepNorth - ice blue
|
||||
256: (25, 60, 120), # Ocean - deep blue
|
||||
512: (120, 80, 150), # Mistlands - purple
|
||||
}
|
||||
|
||||
# Biome to height-based tint mapping for compositing
|
||||
BIOME_HEIGHT_KEYS = {
|
||||
1: 'meadows',
|
||||
2: 'swamp',
|
||||
4: 'mountain',
|
||||
8: 'blackforest',
|
||||
16: 'plains',
|
||||
32: 'ashlands',
|
||||
64: 'deepnorth',
|
||||
256: 'ocean',
|
||||
512: 'mistlands',
|
||||
}
|
||||
|
||||
def read_tile(tile_path):
|
||||
"""Read a compressed tile and return (biomes, heights, forest_factors) as 2D arrays.
|
||||
|
||||
Format: flat array of struct { uint16_t biome; float height; float forestFactor; }
|
||||
= 10 bytes per sample. No header. 1024*1024 = 1,048,576 samples = 10,485,760 bytes.
|
||||
"""
|
||||
with gzip.open(tile_path, 'rb') as f:
|
||||
raw = f.read()
|
||||
|
||||
total_samples = 1024 * 1024
|
||||
expected = total_samples * 10
|
||||
actual = len(raw)
|
||||
|
||||
if actual != expected:
|
||||
print(f" NOTE: Got {actual} bytes, expected {expected} (off by {actual - expected})")
|
||||
|
||||
biomes = [[0]*1024 for _ in range(1024)]
|
||||
heights = [[0.0]*1024 for _ in range(1024)]
|
||||
forests = [[0.0]*1024 for _ in range(1024)]
|
||||
|
||||
n = min(total_samples, actual // 10)
|
||||
for i in range(n):
|
||||
offset = i * 10
|
||||
biome, height, forest = struct.unpack('<Hff', raw[offset:offset+10])
|
||||
row = i // 1024
|
||||
col = i % 1024
|
||||
biomes[row][col] = biome
|
||||
heights[row][col] = height
|
||||
forests[row][col] = forest
|
||||
|
||||
return biomes, heights, forests
|
||||
|
||||
def make_biome_image(tile_biomes):
|
||||
"""Create a biome classification image (1024x1024 RGB)."""
|
||||
img = Image.new('RGB', (1024, 1024))
|
||||
pixels = img.load()
|
||||
for y in range(1024):
|
||||
for x in range(1024):
|
||||
biome = tile_biomes[y][x]
|
||||
# Find the exact biome (bitfield - multiple can be set)
|
||||
# Common Valheim biomes are powers of 2; pick highest
|
||||
found = 0
|
||||
for b in [512, 256, 64, 32, 16, 8, 4, 2, 1]:
|
||||
if biome & b:
|
||||
found = b
|
||||
break
|
||||
pixels[x, y] = BIOME_COLORS.get(found, (50, 50, 50))
|
||||
return img
|
||||
|
||||
def make_height_image(tile_heights):
|
||||
"""Create a heightmap image (grayscale, normalized to 0-255)."""
|
||||
all_heights = [h for row in tile_heights for h in row if not math.isnan(h) and h > -1000]
|
||||
if not all_heights:
|
||||
return Image.new('L', (1024, 1024), 128)
|
||||
h_min, h_max = min(all_heights), max(all_heights)
|
||||
h_range = h_max - h_min if h_max > h_min else 1
|
||||
|
||||
img = Image.new('L', (1024, 1024))
|
||||
pixels = img.load()
|
||||
for y in range(1024):
|
||||
for x in range(1024):
|
||||
h = tile_heights[y][x]
|
||||
if math.isnan(h) or h < -1000:
|
||||
val = 0
|
||||
else:
|
||||
val = int((h - h_min) / h_range * 255)
|
||||
pixels[x, y] = max(0, min(255, val))
|
||||
return img
|
||||
|
||||
def make_composite_image(tile_biomes, tile_heights):
|
||||
"""Create a combined biome+height map using height-shaded biome colors."""
|
||||
all_heights = [h for row in tile_heights for h in row if not math.isnan(h) and h > -1000]
|
||||
if not all_heights:
|
||||
return make_biome_image(tile_biomes)
|
||||
h_min, h_max = min(all_heights), max(all_heights)
|
||||
h_range = h_max - h_min if h_max > h_min else 1
|
||||
|
||||
img = Image.new('RGB', (1024, 1024))
|
||||
pixels = img.load()
|
||||
for y in range(1024):
|
||||
for x in range(1024):
|
||||
biome = tile_biomes[y][x]
|
||||
h = tile_heights[y][x]
|
||||
|
||||
# Find dominant biome
|
||||
found = 0
|
||||
for b in [512, 256, 64, 32, 16, 8, 4, 2, 1]:
|
||||
if biome & b:
|
||||
found = b
|
||||
break
|
||||
|
||||
base = BIOME_COLORS.get(found, (50, 50, 50))
|
||||
|
||||
# Height shading (darker = lower, lighter = higher)
|
||||
if math.isnan(h) or h < -1000:
|
||||
shade = 0.5
|
||||
else:
|
||||
shade = 0.4 + 0.6 * ((h - h_min) / h_range)
|
||||
|
||||
r = int(base[0] * shade)
|
||||
g = int(base[1] * shade)
|
||||
b = int(base[2] * shade)
|
||||
pixels[x, y] = (max(0, min(255, r)),
|
||||
max(0, min(255, g)),
|
||||
max(0, min(255, b)))
|
||||
return img
|
||||
|
||||
def make_biome_and_height_tiles():
|
||||
"""Process all 16 tiles into PNG images."""
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
|
||||
tile_names = sorted([f for f in os.listdir(TILES_DIR) if f.endswith('.bin.gz')])
|
||||
print(f"Found {len(tile_names)} tiles")
|
||||
|
||||
for tile_name in tile_names:
|
||||
tile_path = os.path.join(TILES_DIR, tile_name)
|
||||
base = tile_name.replace('.bin.gz', '')
|
||||
print(f"\nDecoding {tile_name}...")
|
||||
|
||||
biomes, heights, forests = read_tile(tile_path)
|
||||
print(f" Biomes: {len(set(b for row in biomes for b in row))} unique values")
|
||||
|
||||
# Biome image
|
||||
biome_img = make_biome_image(biomes)
|
||||
biome_path = os.path.join(OUTPUT_DIR, f"{base}_biome.png")
|
||||
biome_img.save(biome_path)
|
||||
print(f" Biome: {biome_path}")
|
||||
|
||||
# Height image
|
||||
height_img = make_height_image(heights)
|
||||
height_path = os.path.join(OUTPUT_DIR, f"{base}_height.png")
|
||||
height_img.save(height_path)
|
||||
print(f" Height: {height_path}")
|
||||
|
||||
# Composite image
|
||||
composite_img = make_composite_image(biomes, heights)
|
||||
composite_path = os.path.join(OUTPUT_DIR, f"{base}_composite.png")
|
||||
composite_img.save(composite_path)
|
||||
print(f" Composite: {composite_path}")
|
||||
|
||||
print(f"\nAll tiles processed. Output in {OUTPUT_DIR}/")
|
||||
|
||||
def stitch_full_map():
|
||||
"""Stitch the 4x4 tiles into a full world map."""
|
||||
tile_names = sorted([f for f in os.listdir(TILES_DIR) if f.endswith('.bin.gz')])
|
||||
# Parse tile coords - they're xx-yy.bin.gz
|
||||
tiles = {}
|
||||
for tn in tile_names:
|
||||
parts = tn.replace('.bin.gz', '').split('-')
|
||||
x, y = int(parts[0]), int(parts[1])
|
||||
tiles[(x, y)] = tn
|
||||
|
||||
full_biome = Image.new('RGB', (4096, 4096))
|
||||
full_height = Image.new('L', (4096, 4096))
|
||||
full_composite = Image.new('RGB', (4096, 4096))
|
||||
|
||||
for (tx, ty), tn in sorted(tiles.items()):
|
||||
print(f"Stitching tile {tn} at position ({tx}, {ty})...")
|
||||
biomes, heights, _ = read_tile(os.path.join(TILES_DIR, tn))
|
||||
|
||||
biome_img = make_biome_image(biomes)
|
||||
full_biome.paste(biome_img, (tx * 1024, ty * 1024))
|
||||
|
||||
height_img = make_height_image(heights)
|
||||
full_height.paste(height_img, (tx * 1024, ty * 1024))
|
||||
|
||||
composite_img = make_composite_image(biomes, heights)
|
||||
full_composite.paste(composite_img, (tx * 1024, ty * 1024))
|
||||
|
||||
world_biome_path = os.path.join(OUTPUT_DIR, "world_biome.png")
|
||||
world_height_path = os.path.join(OUTPUT_DIR, "world_height.png")
|
||||
world_composite_path = os.path.join(OUTPUT_DIR, "world_composite.png")
|
||||
|
||||
full_biome.save(world_biome_path)
|
||||
full_height.save(world_height_path)
|
||||
full_composite.save(world_composite_path)
|
||||
|
||||
print(f"\nFull world maps saved:")
|
||||
print(f" Biome: {world_biome_path}")
|
||||
print(f" Height: {world_height_path}")
|
||||
print(f" Composite: {world_composite_path}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
print("=" * 60)
|
||||
print("Valheim Tile Decoder")
|
||||
print("=" * 60)
|
||||
print(f"Reading tiles from: {TILES_DIR}")
|
||||
|
||||
# First check if PIL is available
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
print("ERROR: Pillow not installed. Install with: pip install Pillow")
|
||||
sys.exit(1)
|
||||
|
||||
# Check first tile structure
|
||||
sample_tile = sorted([f for f in os.listdir(TILES_DIR) if f.endswith('.bin.gz')])[0]
|
||||
with gzip.open(os.path.join(TILES_DIR, sample_tile), 'rb') as f:
|
||||
raw = f.read()
|
||||
print(f"Sample tile {sample_tile}: {len(raw)} bytes uncompressed")
|
||||
print(f" Header (first 4 bytes): {raw[:4].hex()} = {struct.unpack('<I', raw[:4])[0]}")
|
||||
print(f" Expected sample size: 10 bytes per pixel × {1024*1024} pixels + 4 header = {(10*1024*1024)+4}")
|
||||
print(f" Actual match: {len(raw) == (10 * 1024 * 1024) + 4}")
|
||||
|
||||
# Process individual tile images
|
||||
make_biome_and_height_tiles()
|
||||
|
||||
# Stitch full world map
|
||||
print("\n" + "=" * 60)
|
||||
print("Stitching full world map (4096x4096)...")
|
||||
print("=" * 60)
|
||||
stitch_full_map()
|
||||
|
||||
print("\nDone!")
|
||||
287
src/parse_valheim_db.py
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Parse Valheim .db world file and extract explored/unexplored map data.
|
||||
Based on reverse-engineered format from ValheimMapCombiner and valheim-save-tools.
|
||||
"""
|
||||
import struct, sys, os
|
||||
from PIL import Image
|
||||
|
||||
DB_PATH = "/tmp/Nerdhalla.db"
|
||||
OUTPUT_DIR = "/tmp/valheim_map_output"
|
||||
|
||||
def read_save_file(path):
|
||||
"""Parse a Valheim .db file and return structured data."""
|
||||
with open(path, 'rb') as f:
|
||||
raw = f.read()
|
||||
|
||||
offset = 0
|
||||
|
||||
# 4 bytes: file length (without SHA512 hash)
|
||||
file_len = struct.unpack_from('<I', raw, offset)[0]
|
||||
offset += 4
|
||||
|
||||
# 20 bytes: data1 (player ID?)
|
||||
data1 = raw[offset:offset+20]
|
||||
offset += 20
|
||||
|
||||
# 4 bytes: world count
|
||||
world_count = struct.unpack_from('<I', raw, offset)[0]
|
||||
offset += 4
|
||||
|
||||
worlds = []
|
||||
for _ in range(world_count):
|
||||
# 8 bytes: world key (world ID)
|
||||
world_key = raw[offset:offset+8]
|
||||
offset += 8
|
||||
|
||||
# 51 bytes: world data 1
|
||||
world_data1 = raw[offset:offset+51]
|
||||
offset += 51
|
||||
|
||||
# 1 byte: map data bool
|
||||
map_data_bool = raw[offset]
|
||||
offset += 1
|
||||
|
||||
# 4 bytes: map data length
|
||||
map_data_len = struct.unpack_from('<I', raw, offset)[0]
|
||||
offset += 4
|
||||
|
||||
# map data
|
||||
map_data_raw = raw[offset:offset+map_data_len]
|
||||
offset += map_data_len
|
||||
|
||||
map_data = parse_map_data(map_data_raw)
|
||||
|
||||
worlds.append({
|
||||
'world_key': world_key,
|
||||
'world_data1': world_data1,
|
||||
'map_data_bool': map_data_bool,
|
||||
'map_data_len': map_data_len,
|
||||
'map_data': map_data,
|
||||
})
|
||||
|
||||
# Remaining data (minus 64 bytes SHA512 hash)
|
||||
data2 = raw[offset:-64]
|
||||
sha512_hash = raw[-64:]
|
||||
|
||||
return {
|
||||
'file_len': file_len,
|
||||
'data1': data1,
|
||||
'world_count': world_count,
|
||||
'worlds': worlds,
|
||||
'data2': data2,
|
||||
'sha512_hash': sha512_hash,
|
||||
}
|
||||
|
||||
def parse_map_data(map_data):
|
||||
"""Parse the map data section containing explored bitmap and pins."""
|
||||
offset = 0
|
||||
|
||||
# num1 (int32) - version?
|
||||
num1 = struct.unpack_from('<i', map_data, offset)[0]
|
||||
offset += 4
|
||||
|
||||
# num2 (int32) - ?
|
||||
num2 = struct.unpack_from('<i', map_data, offset)[0]
|
||||
offset += 4
|
||||
|
||||
# Explored data - remaining until pin data
|
||||
# The explored bitmap is 4096*4096/8 = 2,097,152 bytes
|
||||
# But it's stored as int32 array, so we need to find the pin count
|
||||
|
||||
# Read int32s until we find what looks like pin count
|
||||
# Pin count is typically a small number (< 1000)
|
||||
# The explored bitmap is 4096*4096 bits = 2,097,152 bytes = 524,288 int32s
|
||||
|
||||
# Let's read the whole thing as int32 array and find the pin count
|
||||
remaining = len(map_data) - offset
|
||||
int32_count = remaining // 4
|
||||
|
||||
# The explored bitmap is 4096*4096 bits = 2,097,152 bytes
|
||||
# But it might be stored differently. Let's read it all and figure it out.
|
||||
|
||||
# Actually, the explored data is stored as a bitmap where each bit = one map pixel
|
||||
# Valheim map is 4096x4096 = 16,777,216 pixels
|
||||
# At 1 bit per pixel = 2,097,152 bytes = 524,288 int32s
|
||||
|
||||
# But the actual data might be smaller if the map hasn't been fully explored
|
||||
# Let's just read the raw bytes and try to interpret
|
||||
|
||||
explored_bytes = map_data[offset:]
|
||||
|
||||
return {
|
||||
'num1': num1,
|
||||
'num2': num2,
|
||||
'explored_raw': explored_bytes,
|
||||
}
|
||||
|
||||
def extract_explored_bitmap(save_data):
|
||||
"""Extract the explored/unexplored bitmap from parsed save data."""
|
||||
if not save_data['worlds']:
|
||||
print("No worlds found in save file")
|
||||
return None
|
||||
|
||||
world = save_data['worlds'][0]
|
||||
map_data = world['map_data']
|
||||
|
||||
raw = map_data['explored_raw']
|
||||
print(f"Explored data size: {len(raw)} bytes")
|
||||
|
||||
# Valheim map is 4096x4096 pixels
|
||||
# Explored data is a bitmap: 1 bit per pixel
|
||||
# Expected size: 4096 * 4096 / 8 = 2,097,152 bytes
|
||||
|
||||
# But the data might be stored as int32s (4 bytes per value)
|
||||
# Let's check if it looks like a bitmap or int32 array
|
||||
|
||||
# Try interpreting as bitmap directly
|
||||
expected_bitmap = 4096 * 4096 // 8
|
||||
|
||||
if len(raw) >= expected_bitmap:
|
||||
print(f"Data is at least {expected_bitmap} bytes - treating as bitmap")
|
||||
bitmap = raw[:expected_bitmap]
|
||||
else:
|
||||
print(f"Data is {len(raw)} bytes, less than expected {expected_bitmap}")
|
||||
print("Trying to interpret as int32 array...")
|
||||
|
||||
# Read as int32s and see what we get
|
||||
vals = struct.unpack_from(f'<{len(raw)//4}I', raw)
|
||||
print(f"First 20 int32 values: {vals[:20]}")
|
||||
print(f"Max value: {max(vals)}, Min value: {min(vals)}")
|
||||
|
||||
# If values are 0 or 1, it might be a per-pixel explored flag
|
||||
if max(vals) <= 1:
|
||||
print("Values are 0/1 - treating as per-pixel explored flags")
|
||||
bitmap_bytes = bytearray()
|
||||
for i in range(0, len(vals), 8):
|
||||
byte = 0
|
||||
for j in range(8):
|
||||
if i + j < len(vals) and vals[i + j]:
|
||||
byte |= (1 << j)
|
||||
bitmap_bytes.append(byte)
|
||||
bitmap = bytes(bitmap_bytes)
|
||||
else:
|
||||
# Try treating raw bytes as bitmap directly
|
||||
bitmap = raw[:expected_bitmap]
|
||||
|
||||
return bitmap
|
||||
|
||||
def bitmap_to_image(bitmap, width=4096, height=4096):
|
||||
"""Convert explored bitmap to a grayscale image (white=explored, black=unexplored)."""
|
||||
if bitmap is None:
|
||||
return None
|
||||
|
||||
img = Image.new('L', (width, height))
|
||||
pixels = img.load()
|
||||
|
||||
bytes_per_row = (width + 7) // 8
|
||||
|
||||
for y in range(height):
|
||||
row_start = y * bytes_per_row
|
||||
for x in range(width):
|
||||
byte_idx = row_start + (x // 8)
|
||||
bit_idx = x % 8
|
||||
if byte_idx < len(bitmap):
|
||||
explored = (bitmap[byte_idx] >> bit_idx) & 1
|
||||
pixels[x, y] = 255 if explored else 0
|
||||
else:
|
||||
pixels[x, y] = 0
|
||||
|
||||
return img
|
||||
|
||||
def create_fog_overlay(explored_img):
|
||||
"""Create a fog-of-war overlay: explored=transparent, unexplored=dark overlay."""
|
||||
if explored_img is None:
|
||||
return None
|
||||
|
||||
# Create RGBA image: unexplored areas get a dark overlay
|
||||
overlay = Image.new('RGBA', (4096, 4096))
|
||||
overlay_pixels = overlay.load()
|
||||
explored_pixels = explored_img.load()
|
||||
|
||||
for y in range(4096):
|
||||
for x in range(4096):
|
||||
if explored_pixels[x, y] > 128:
|
||||
# Explored - transparent
|
||||
overlay_pixels[x, y] = (0, 0, 0, 0)
|
||||
else:
|
||||
# Unexplored - dark semi-transparent
|
||||
overlay_pixels[x, y] = (0, 0, 0, 200)
|
||||
|
||||
return overlay
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("Valheim .db File Parser — Explored Map Data")
|
||||
print("=" * 60)
|
||||
|
||||
print(f"\nReading: {DB_PATH}")
|
||||
print(f"File size: {os.path.getsize(DB_PATH)} bytes")
|
||||
|
||||
save_data = read_save_file(DB_PATH)
|
||||
|
||||
print(f"\nWorld count: {save_data['world_count']}")
|
||||
for i, world in enumerate(save_data['worlds']):
|
||||
print(f"\nWorld {i}:")
|
||||
print(f" World key: {world['world_key'].hex()}")
|
||||
print(f" Map data bool: {world['map_data_bool']}")
|
||||
print(f" Map data length: {world['map_data_len']}")
|
||||
md = world['map_data']
|
||||
print(f" num1: {md['num1']}")
|
||||
print(f" num2: {md['num2']}")
|
||||
print(f" Explored raw size: {len(md['explored_raw'])} bytes")
|
||||
|
||||
# Show first few bytes of explored data
|
||||
print(f" First 32 bytes: {md['explored_raw'][:32].hex()}")
|
||||
|
||||
# Extract explored bitmap
|
||||
print("\n" + "=" * 60)
|
||||
print("Extracting explored bitmap...")
|
||||
bitmap = extract_explored_bitmap(save_data)
|
||||
|
||||
if bitmap:
|
||||
print(f"\nBitmap size: {len(bitmap)} bytes")
|
||||
print(f"Expected for 4096x4096: {4096*4096//8} bytes")
|
||||
|
||||
# Convert to image
|
||||
explored_img = bitmap_to_image(bitmap)
|
||||
if explored_img:
|
||||
explored_path = os.path.join(OUTPUT_DIR, "explored.png")
|
||||
explored_img.save(explored_path)
|
||||
print(f"\nExplored map saved: {explored_path}")
|
||||
|
||||
# Count explored vs unexplored pixels
|
||||
explored_pixels = sum(1 for y in range(4096) for x in range(4096) if explored_img.getpixel((x, y)) > 128)
|
||||
total = 4096 * 4096
|
||||
pct = explored_pixels / total * 100
|
||||
print(f"Explored: {explored_pixels:,} / {total:,} pixels ({pct:.1f}%)")
|
||||
|
||||
# Create fog overlay
|
||||
overlay = create_fog_overlay(explored_img)
|
||||
if overlay:
|
||||
overlay_path = os.path.join(OUTPUT_DIR, "fog_overlay.png")
|
||||
overlay.save(overlay_path)
|
||||
print(f"Fog overlay saved: {overlay_path}")
|
||||
|
||||
# Also create a composite with the world map
|
||||
composite = Image.open(os.path.join(OUTPUT_DIR, "world_composite.png")).convert('RGBA')
|
||||
composite.paste(overlay, (0, 0), overlay)
|
||||
fogged_path = os.path.join(OUTPUT_DIR, "world_fogged.png")
|
||||
composite.save(fogged_path)
|
||||
print(f"Fogged world map saved: {fogged_path}")
|
||||
else:
|
||||
print("Could not extract explored bitmap")
|
||||
# Try alternative: read raw bytes and dump structure
|
||||
print("\nDumping raw structure for analysis...")
|
||||
with open(DB_PATH, 'rb') as f:
|
||||
raw = f.read()
|
||||
|
||||
# Show first 200 bytes
|
||||
print(f"First 200 bytes hex dump:")
|
||||
for i in range(0, min(200, len(raw)), 16):
|
||||
hex_str = ' '.join(f'{b:02x}' for b in raw[i:i+16])
|
||||
ascii_str = ''.join(chr(b) if 32 <= b < 127 else '.' for b in raw[i:i+16])
|
||||
print(f" {i:04x}: {hex_str:48s} {ascii_str}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
283
src/parse_valheim_zpackage.py
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Python ZPackage parser for Valheim .db files.
|
||||
Extracts cartography table data (explored map bitmap + pins).
|
||||
"""
|
||||
import struct, gzip, io, sys, os
|
||||
from PIL import Image
|
||||
|
||||
DB_PATH = "/tmp/Nerdhalla.db"
|
||||
OUTPUT_DIR = "/tmp/valheim_map_output"
|
||||
|
||||
# Known prefab hashes for cartography table
|
||||
# Valheim uses fnv-1a 64-bit hashes for prefab names
|
||||
CARTOGRAPHY_TABLE_HASH = None # We'll search for it
|
||||
|
||||
def fnv1a_64(data):
|
||||
"""FNV-1a 64-bit hash (Valheim's prefab hashing)."""
|
||||
h = 0xcbf29ce484222325
|
||||
for b in data:
|
||||
h ^= b
|
||||
h = (h * 0x100000001b3) & 0xffffffffffffffff
|
||||
return h
|
||||
|
||||
# Pre-calculate common prefab hashes
|
||||
PREFAB_HASHES = {
|
||||
'CartographyTable': None,
|
||||
}
|
||||
|
||||
class ZPackage:
|
||||
"""Read-only ZPackage parser (Valheim binary serialization format)."""
|
||||
|
||||
def __init__(self, data):
|
||||
self.data = data
|
||||
self.pos = 0
|
||||
|
||||
def read_int32(self):
|
||||
v = struct.unpack_from('<i', self.data, self.pos)[0]
|
||||
self.pos += 4
|
||||
return v
|
||||
|
||||
def read_uint32(self):
|
||||
v = struct.unpack_from('<I', self.data, self.pos)[0]
|
||||
self.pos += 4
|
||||
return v
|
||||
|
||||
def read_int64(self):
|
||||
v = struct.unpack_from('<q', self.data, self.pos)[0]
|
||||
self.pos += 8
|
||||
return v
|
||||
|
||||
def read_uint64(self):
|
||||
v = struct.unpack_from('<Q', self.data, self.pos)[0]
|
||||
self.pos += 8
|
||||
return v
|
||||
|
||||
def read_float(self):
|
||||
v = struct.unpack_from('<f', self.data, self.pos)[0]
|
||||
self.pos += 4
|
||||
return v
|
||||
|
||||
def read_double(self):
|
||||
v = struct.unpack_from('<d', self.data, self.pos)[0]
|
||||
self.pos += 8
|
||||
return v
|
||||
|
||||
def read_byte(self):
|
||||
v = self.data[self.pos]
|
||||
self.pos += 1
|
||||
return v
|
||||
|
||||
def read_bool(self):
|
||||
return self.read_byte() != 0
|
||||
|
||||
def read_bytes(self, n):
|
||||
v = self.data[self.pos:self.pos+n]
|
||||
self.pos += n
|
||||
return v
|
||||
|
||||
def read_string_length(self):
|
||||
"""Variable-length integer (7-bit per byte, LEB128-like)."""
|
||||
result = 0
|
||||
shift = 0
|
||||
while True:
|
||||
byte = self.read_byte()
|
||||
result |= (byte & 0x7F) << shift
|
||||
shift += 7
|
||||
if (byte & 0x80) == 0:
|
||||
break
|
||||
return result
|
||||
|
||||
def read_string(self):
|
||||
length = self.read_string_length()
|
||||
if length == 0:
|
||||
return ""
|
||||
s = self.data[self.pos:self.pos+length].decode('utf-8', errors='replace')
|
||||
self.pos += length
|
||||
return s
|
||||
|
||||
def read_short(self):
|
||||
v = struct.unpack_from('<h', self.data, self.pos)[0]
|
||||
self.pos += 2
|
||||
return v
|
||||
|
||||
def read_ushort(self):
|
||||
v = struct.unpack_from('<H', self.data, self.pos)[0]
|
||||
self.pos += 2
|
||||
return v
|
||||
|
||||
def read_compressed(self):
|
||||
"""Read a compressed ZPackage section."""
|
||||
length = self.read_int32()
|
||||
compressed = self.read_bytes(length)
|
||||
try:
|
||||
decompressed = gzip.decompress(compressed)
|
||||
return ZPackage(decompressed)
|
||||
except:
|
||||
return None
|
||||
|
||||
def read_length_prefixed_bytes(self):
|
||||
length = self.read_int32()
|
||||
return self.read_bytes(length)
|
||||
|
||||
def skip(self, n):
|
||||
self.pos += n
|
||||
|
||||
def remaining(self):
|
||||
return len(self.data) - self.pos
|
||||
|
||||
def tell(self):
|
||||
return self.pos
|
||||
|
||||
|
||||
def parse_db(path):
|
||||
"""Parse the .db file and extract cartography table data."""
|
||||
with open(path, 'rb') as f:
|
||||
raw = f.read()
|
||||
|
||||
print(f"File size: {len(raw)} bytes")
|
||||
zp = ZPackage(raw)
|
||||
|
||||
# Header
|
||||
world_version = zp.read_int32()
|
||||
print(f"World version: {world_version}")
|
||||
|
||||
net_time = zp.read_double()
|
||||
print(f"Net time: {net_time}")
|
||||
|
||||
my_id = zp.read_int64()
|
||||
next_uid = zp.read_uint32()
|
||||
num_zdos = zp.read_int32()
|
||||
print(f"My ID: {my_id}, Next UID: {next_uid}, ZDOs: {num_zdos}")
|
||||
|
||||
# Calculate the hash for CartographyTable
|
||||
cart_hash = fnv1a_64(b'CartographyTable')
|
||||
print(f"CartographyTable hash: {cart_hash}")
|
||||
|
||||
# Also check for 'piece_cartographytable' (the actual in-game name)
|
||||
piece_hash = fnv1a_64(b'piece_cartographytable')
|
||||
print(f"piece_cartographytable hash: {piece_hash}")
|
||||
|
||||
# Skip through ZDOs looking for cartography table
|
||||
cart_found = None
|
||||
zdo_count = 0
|
||||
|
||||
print(f"\nScanning {num_zdos} ZDOs for cartography table...")
|
||||
|
||||
for i in range(num_zdos):
|
||||
if i % 100000 == 0 and i > 0:
|
||||
print(f" Scanned {i}/{num_zdos}...")
|
||||
|
||||
zdo_start = zp.tell()
|
||||
|
||||
# ZDO header
|
||||
uid = zp.read_int64()
|
||||
prefab = zp.read_int32()
|
||||
owner = zp.read_int32()
|
||||
data_type = zp.read_int32()
|
||||
revision = zp.read_int32()
|
||||
|
||||
# Check if this is a cartography table
|
||||
if prefab == cart_hash or prefab == piece_hash:
|
||||
print(f"\n *** FOUND Cartography Table at ZDO {i} ***")
|
||||
print(f" UID: {uid}, Prefab hash: {prefab}, Owner: {owner}")
|
||||
print(f" Data type: {data_type}, Revision: {revision}")
|
||||
cart_found = (i, uid, prefab, zp.tell())
|
||||
break
|
||||
|
||||
# Skip ZDO data based on data_type
|
||||
if data_type == 0:
|
||||
# No data
|
||||
pass
|
||||
elif data_type == 1:
|
||||
# Has persistent data
|
||||
_ = zp.read_int32() # data length
|
||||
# Skip the data
|
||||
# Actually we need to parse it properly
|
||||
# For now, just skip based on remaining data
|
||||
pass
|
||||
elif data_type == 2:
|
||||
# Has ZDO data (key-value pairs)
|
||||
num_keys = zp.read_byte()
|
||||
for _ in range(num_keys):
|
||||
key_hash = zp.read_int32()
|
||||
value_type = zp.read_byte()
|
||||
if value_type == 0: # float
|
||||
zp.skip(4)
|
||||
elif value_type == 1: # Vector3
|
||||
zp.skip(12)
|
||||
elif value_type == 2: # Quaternion
|
||||
zp.skip(16)
|
||||
elif value_type == 3: # int
|
||||
zp.skip(4)
|
||||
elif value_type == 4: # long
|
||||
zp.skip(8)
|
||||
elif value_type == 5: # string
|
||||
_ = zp.read_string()
|
||||
elif value_type == 6: # byte array
|
||||
length = zp.read_int32()
|
||||
zp.skip(length)
|
||||
else:
|
||||
print(f" Unknown value type {value_type} at {zp.tell()}")
|
||||
break
|
||||
elif data_type == 3:
|
||||
# Both persistent and ZDO data
|
||||
_ = zp.read_int32() # persistent data length
|
||||
num_keys = zp.read_byte()
|
||||
for _ in range(num_keys):
|
||||
key_hash = zp.read_int32()
|
||||
value_type = zp.read_byte()
|
||||
if value_type == 0:
|
||||
zp.skip(4)
|
||||
elif value_type == 1:
|
||||
zp.skip(12)
|
||||
elif value_type == 2:
|
||||
zp.skip(16)
|
||||
elif value_type == 3:
|
||||
zp.skip(4)
|
||||
elif value_type == 4:
|
||||
zp.skip(8)
|
||||
elif value_type == 5:
|
||||
_ = zp.read_string()
|
||||
elif value_type == 6:
|
||||
length = zp.read_int32()
|
||||
zp.skip(length)
|
||||
else:
|
||||
break
|
||||
|
||||
# Skip sector data
|
||||
# Each ZDO has sector info: 2 shorts (sector x, y) + 3 floats (position) + 4 floats (rotation)
|
||||
# Actually this varies by version. Let's just skip to next ZDO by finding it.
|
||||
|
||||
# For world version >= 33, ZDOs have a compact format
|
||||
# Let's just try to find the next ZDO by looking for the pattern
|
||||
|
||||
if cart_found:
|
||||
print(f"\nCartography table found at ZDO index {cart_found[0]}")
|
||||
else:
|
||||
print(f"\nCartography table not found in {num_zdos} ZDOs")
|
||||
print("The explored map data might be stored differently.")
|
||||
print("Let me check the character save format instead...")
|
||||
|
||||
return cart_found
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("Valheim Cartography Table Extractor")
|
||||
print("=" * 60)
|
||||
|
||||
result = parse_db(DB_PATH)
|
||||
|
||||
if result:
|
||||
print("\nNext steps: extract the explored bitmap from the ZDO data")
|
||||
else:
|
||||
print("\nThe cartography table data may be stored in a different format.")
|
||||
print("Let me check the Zones section of the .db file...")
|
||||
|
||||
# The explored map data might be in the Zones section
|
||||
# Or it might be stored per-player in .fch files
|
||||
print("\nAlternative approach: parse the .db Zones section for explored data")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
266
src/valheim_db.py
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
#!/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")
|
||||