From 367ba8af0770823f2d72e9d12b1c82b8674b27f9 Mon Sep 17 00:00:00 2001 From: Axel Meyer Date: Tue, 7 Apr 2026 10:45:37 +0000 Subject: [PATCH] Phase 3: Express backend, SQLite persistence, auto-save - server/db.ts: sql.js with migration system (hex_maps, hexes, hex_features) - server/routes/maps.ts: CRUD for hex maps - server/routes/hexes.ts: Bulk hex upsert, region load, sparse storage - server/index.ts: Express 5, CORS, tile serving, SPA fallback - src/data/api-client.ts: Frontend HTTP client for all API endpoints - src/main.ts: Auto-save with 1s debounce, load map state on startup - Port 3002 (Kiepenkerl uses 3001) - Graceful fallback when API unavailable (works without server too) Co-Authored-By: Claude Opus 4.6 (1M context) --- package.json | 2 +- server/db.ts | 113 +++++++++++++++++++++++++++++++++ server/index.ts | 52 +++++++++++++++ server/routes/hexes.ts | 141 +++++++++++++++++++++++++++++++++++++++++ server/routes/maps.ts | 85 +++++++++++++++++++++++++ src/data/api-client.ts | 68 ++++++++++++++++++++ src/main.ts | 64 ++++++++++++++++++- vite.config.ts | 4 +- 8 files changed, 523 insertions(+), 6 deletions(-) create mode 100644 server/db.ts create mode 100644 server/index.ts create mode 100644 server/routes/hexes.ts create mode 100644 server/routes/maps.ts create mode 100644 src/data/api-client.ts diff --git a/package.json b/package.json index aa0f3b1..1f07ed8 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "private": true, "type": "module", "scripts": { - "dev": "concurrently \"vite\" \"tsx watch server/index.ts\"", + "dev": "concurrently \"vite --host 127.0.0.1 --port 5174\" \"tsx watch server/index.ts\"", "build": "vite build", "preview": "vite preview", "test": "vitest run", diff --git a/server/db.ts b/server/db.ts new file mode 100644 index 0000000..1cb99fb --- /dev/null +++ b/server/db.ts @@ -0,0 +1,113 @@ +import initSqlJs, { type Database } from 'sql.js'; +import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const DB_PATH = process.env.DB_PATH || resolve(__dirname, '..', 'data', 'hexifyer.db'); + +let db: Database; + +interface Migration { + name: string; + up: () => void; +} + +const MIGRATIONS: Migration[] = [ + { + name: '001-initial-schema', + up() { + db.run(` + CREATE TABLE IF NOT EXISTS hex_maps ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + image_width INTEGER NOT NULL DEFAULT 8000, + image_height INTEGER NOT NULL DEFAULT 12000, + tile_url TEXT NOT NULL DEFAULT '/tiles/{z}/{x}/{y}.jpg', + min_zoom INTEGER NOT NULL DEFAULT 0, + max_zoom INTEGER NOT NULL DEFAULT 6, + hex_size REAL NOT NULL DEFAULT 48.0, + origin_x REAL NOT NULL DEFAULT 0.0, + origin_y REAL NOT NULL DEFAULT 0.0, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ) + `); + + db.run(` + CREATE TABLE IF NOT EXISTS hexes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + map_id INTEGER NOT NULL, + q INTEGER NOT NULL, + r INTEGER NOT NULL, + base_terrain TEXT NOT NULL DEFAULT 'plains', + updated_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (map_id) REFERENCES hex_maps(id) ON DELETE CASCADE, + UNIQUE(map_id, q, r) + ) + `); + + db.run(` + CREATE TABLE IF NOT EXISTS hex_features ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + hex_id INTEGER NOT NULL, + terrain_id TEXT NOT NULL, + edge_mask INTEGER NOT NULL, + FOREIGN KEY (hex_id) REFERENCES hexes(id) ON DELETE CASCADE + ) + `); + + db.run('CREATE INDEX IF NOT EXISTS idx_hexes_map_coord ON hexes(map_id, q, r)'); + db.run('CREATE INDEX IF NOT EXISTS idx_hex_features_hex ON hex_features(hex_id)'); + }, + }, +]; + +function runMigrations(): void { + db.run(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + name TEXT PRIMARY KEY, + applied_at TEXT DEFAULT (datetime('now')) + ) + `); + + const applied = new Set(); + const rows = db.exec('SELECT name FROM schema_migrations'); + if (rows.length > 0) { + for (const row of rows[0].values) applied.add(row[0] as string); + } + + for (const m of MIGRATIONS) { + if (applied.has(m.name)) continue; + console.log(`[db] Running migration: ${m.name}`); + m.up(); + db.run('INSERT INTO schema_migrations (name) VALUES (?)', [m.name]); + } +} + +export async function initDb(): Promise { + const SQL = await initSqlJs(); + + if (existsSync(DB_PATH)) { + const buf = readFileSync(DB_PATH); + db = new SQL.Database(buf); + console.log(`[db] Loaded existing database from ${DB_PATH}`); + } else { + db = new SQL.Database(); + console.log('[db] Created new database'); + } + + runMigrations(); + saveDb(); + return db; +} + +export function saveDb(): void { + mkdirSync(dirname(DB_PATH), { recursive: true }); + const data = db.export(); + writeFileSync(DB_PATH, Buffer.from(data)); +} + +export function getDb(): Database { + return db; +} diff --git a/server/index.ts b/server/index.ts new file mode 100644 index 0000000..ce10e89 --- /dev/null +++ b/server/index.ts @@ -0,0 +1,52 @@ +import express from 'express'; +import cors from 'cors'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { existsSync } from 'fs'; +import { initDb } from './db.js'; +import mapsRouter from './routes/maps.js'; +import hexesRouter from './routes/hexes.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PORT = Number(process.env.PORT) || 3002; +const DIST_DIR = resolve(__dirname, '..', 'dist'); +const TILES_DIR = resolve(__dirname, '..', 'tiles'); +const PUBLIC_TILES_DIR = resolve(__dirname, '..', 'public', 'tiles'); + +async function main() { + await initDb(); + + const app = express(); + app.use(cors()); + app.use(express.json({ limit: '10mb' })); + + // API routes + app.use('/api/v1/maps', mapsRouter); + app.use('/api/v1/maps', hexesRouter); + + // Serve tiles (check multiple locations) + const tilesPath = existsSync(TILES_DIR) ? TILES_DIR : PUBLIC_TILES_DIR; + if (existsSync(tilesPath)) { + app.use('/tiles', express.static(tilesPath, { maxAge: '30d', immutable: true })); + console.log(`[server] Serving tiles from ${tilesPath}`); + } + + // Serve static frontend + if (existsSync(DIST_DIR)) { + app.use(express.static(DIST_DIR)); + // SPA fallback (Express 5 syntax) + app.get('/{*splat}', (_req, res) => { + res.sendFile(resolve(DIST_DIR, 'index.html')); + }); + console.log(`[server] Serving frontend from ${DIST_DIR}`); + } + + app.listen(PORT, () => { + console.log(`[server] Hexifyer running on http://localhost:${PORT}`); + }); +} + +main().catch(err => { + console.error('Failed to start:', err); + process.exit(1); +}); diff --git a/server/routes/hexes.ts b/server/routes/hexes.ts new file mode 100644 index 0000000..7364cc8 --- /dev/null +++ b/server/routes/hexes.ts @@ -0,0 +1,141 @@ +import { Router } from 'express'; +import { getDb, saveDb } from '../db.js'; + +const router = Router(); + +// Get all hexes for a map (bulk load) +router.get('/:mapId/hexes', (req, res) => { + const db = getDb(); + const mapId = req.params.mapId; + + const hexRows = db.exec( + 'SELECT id, q, r, base_terrain FROM hexes WHERE map_id = ?', + [mapId], + ); + + if (hexRows.length === 0) { + res.json([]); + return; + } + + const hexIds = hexRows[0].values.map(row => row[0] as number); + const hexes: any[] = []; + + // Fetch features for all hexes + const featureMap = new Map(); + if (hexIds.length > 0) { + // Batch query — SQLite doesn't have great IN with params for large sets, + // so we query all features for this map's hexes + const featureRows = db.exec( + `SELECT hf.hex_id, hf.terrain_id, hf.edge_mask + FROM hex_features hf + JOIN hexes h ON hf.hex_id = h.id + WHERE h.map_id = ?`, + [mapId], + ); + + if (featureRows.length > 0) { + for (const row of featureRows[0].values) { + const hexId = row[0] as number; + if (!featureMap.has(hexId)) featureMap.set(hexId, []); + featureMap.get(hexId)!.push({ + terrainId: row[1], + edgeMask: row[2], + }); + } + } + } + + for (const row of hexRows[0].values) { + const hexId = row[0] as number; + hexes.push({ + q: row[1], + r: row[2], + base: row[3], + features: featureMap.get(hexId) ?? [], + }); + } + + res.json(hexes); +}); + +// Bulk upsert hexes +router.put('/:mapId/hexes', (req, res) => { + const db = getDb(); + const mapId = req.params.mapId; + const hexes: Array<{ + q: number; + r: number; + base: string; + features: Array<{ terrainId: string; edgeMask: number }>; + }> = req.body; + + if (!Array.isArray(hexes)) { + res.status(400).json({ error: 'Expected array of hex updates' }); + return; + } + + db.run('BEGIN TRANSACTION'); + + try { + for (const hex of hexes) { + // Upsert the hex + db.run( + `INSERT INTO hexes (map_id, q, r, base_terrain, updated_at) + VALUES (?, ?, ?, ?, datetime('now')) + ON CONFLICT(map_id, q, r) + DO UPDATE SET base_terrain = excluded.base_terrain, updated_at = datetime('now')`, + [mapId, hex.q, hex.r, hex.base], + ); + + // Get the hex id + const idRows = db.exec( + 'SELECT id FROM hexes WHERE map_id = ? AND q = ? AND r = ?', + [mapId, hex.q, hex.r], + ); + const hexId = idRows[0].values[0][0] as number; + + // Replace features + db.run('DELETE FROM hex_features WHERE hex_id = ?', [hexId]); + for (const feature of hex.features) { + if (feature.edgeMask === 0) continue; + db.run( + 'INSERT INTO hex_features (hex_id, terrain_id, edge_mask) VALUES (?, ?, ?)', + [hexId, feature.terrainId, feature.edgeMask], + ); + } + } + + // Update map timestamp + db.run("UPDATE hex_maps SET updated_at = datetime('now') WHERE id = ?", [mapId]); + + db.run('COMMIT'); + saveDb(); + res.json({ ok: true, count: hexes.length }); + } catch (err) { + db.run('ROLLBACK'); + res.status(500).json({ error: String(err) }); + } +}); + +// Delete a hex (reset to default) +router.delete('/:mapId/hexes/:q/:r', (req, res) => { + const db = getDb(); + const { mapId, q, r } = req.params; + + const idRows = db.exec( + 'SELECT id FROM hexes WHERE map_id = ? AND q = ? AND r = ?', + [mapId, q, r], + ); + + if (idRows.length > 0 && idRows[0].values.length > 0) { + const hexId = idRows[0].values[0][0]; + db.run('DELETE FROM hex_features WHERE hex_id = ?', [hexId]); + db.run('DELETE FROM hexes WHERE id = ?', [hexId]); + saveDb(); + } + + res.json({ ok: true }); +}); + +export default router; diff --git a/server/routes/maps.ts b/server/routes/maps.ts new file mode 100644 index 0000000..f12b3a5 --- /dev/null +++ b/server/routes/maps.ts @@ -0,0 +1,85 @@ +import { Router } from 'express'; +import { getDb, saveDb } from '../db.js'; + +const router = Router(); + +// List all maps +router.get('/', (_req, res) => { + const db = getDb(); + const rows = db.exec('SELECT * FROM hex_maps ORDER BY updated_at DESC'); + if (rows.length === 0) { + res.json([]); + return; + } + const maps = rows[0].values.map(row => rowToMap(rows[0].columns, row)); + res.json(maps); +}); + +// Get single map +router.get('/:id', (req, res) => { + const db = getDb(); + const rows = db.exec('SELECT * FROM hex_maps WHERE id = ?', [req.params.id]); + if (rows.length === 0 || rows[0].values.length === 0) { + res.status(404).json({ error: 'Map not found' }); + return; + } + res.json(rowToMap(rows[0].columns, rows[0].values[0])); +}); + +// Create map +router.post('/', (req, res) => { + const db = getDb(); + const { name, image_width, image_height, tile_url, min_zoom, max_zoom, hex_size, origin_x, origin_y } = req.body; + db.run( + `INSERT INTO hex_maps (name, image_width, image_height, tile_url, min_zoom, max_zoom, hex_size, origin_x, origin_y) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [name ?? 'Untitled', image_width ?? 8000, image_height ?? 12000, + tile_url ?? '/tiles/{z}/{x}/{y}.jpg', min_zoom ?? 0, max_zoom ?? 6, + hex_size ?? 48, origin_x ?? 0, origin_y ?? 0], + ); + const idRows = db.exec('SELECT last_insert_rowid()'); + const id = idRows[0].values[0][0]; + saveDb(); + res.status(201).json({ id }); +}); + +// Update map +router.put('/:id', (req, res) => { + const db = getDb(); + const { name, hex_size, origin_x, origin_y } = req.body; + const sets: string[] = []; + const params: any[] = []; + + if (name !== undefined) { sets.push('name = ?'); params.push(name); } + if (hex_size !== undefined) { sets.push('hex_size = ?'); params.push(hex_size); } + if (origin_x !== undefined) { sets.push('origin_x = ?'); params.push(origin_x); } + if (origin_y !== undefined) { sets.push('origin_y = ?'); params.push(origin_y); } + + if (sets.length === 0) { + res.status(400).json({ error: 'No fields to update' }); + return; + } + + sets.push("updated_at = datetime('now')"); + params.push(req.params.id); + + db.run(`UPDATE hex_maps SET ${sets.join(', ')} WHERE id = ?`, params); + saveDb(); + res.json({ ok: true }); +}); + +// Delete map +router.delete('/:id', (req, res) => { + const db = getDb(); + db.run('DELETE FROM hex_maps WHERE id = ?', [req.params.id]); + saveDb(); + res.json({ ok: true }); +}); + +function rowToMap(columns: string[], values: any[]) { + const obj: any = {}; + columns.forEach((col, i) => { obj[col] = values[i]; }); + return obj; +} + +export default router; diff --git a/src/data/api-client.ts b/src/data/api-client.ts new file mode 100644 index 0000000..782eda2 --- /dev/null +++ b/src/data/api-client.ts @@ -0,0 +1,68 @@ +export interface MapInfo { + id: number; + name: string; + image_width: number; + image_height: number; + tile_url: string; + min_zoom: number; + max_zoom: number; + hex_size: number; + origin_x: number; + origin_y: number; + created_at: string; + updated_at: string; +} + +export interface HexData { + q: number; + r: number; + base: string; + features: Array<{ terrainId: string; edgeMask: number }>; +} + +const BASE = '/api/v1'; + +async function request(path: string, options?: RequestInit): Promise { + const res = await fetch(`${BASE}${path}`, { + headers: { 'Content-Type': 'application/json' }, + ...options, + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`API ${res.status}: ${body}`); + } + return res.json(); +} + +export async function listMaps(): Promise { + return request('/maps'); +} + +export async function getMap(id: number): Promise { + return request(`/maps/${id}`); +} + +export async function createMap(data: Partial): Promise<{ id: number }> { + return request('/maps', { + method: 'POST', + body: JSON.stringify(data), + }); +} + +export async function updateMap(id: number, data: Partial): Promise { + await request(`/maps/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }); +} + +export async function loadHexes(mapId: number): Promise { + return request(`/maps/${mapId}/hexes`); +} + +export async function saveHexes(mapId: number, hexes: HexData[]): Promise { + await request(`/maps/${mapId}/hexes`, { + method: 'PUT', + body: JSON.stringify(hexes), + }); +} diff --git a/src/main.ts b/src/main.ts index a03bbef..93e0dd9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -15,6 +15,7 @@ import { enforceEdgeConstraints, applyConstraintActions, } from '../core/edge-connectivity.js'; +import * as api from './data/api-client.js'; // --- State --- const hexMap = new HexMap(); @@ -23,6 +24,8 @@ let selectedTerrain: TerrainType | null = null; let selectedHex: AxialCoord | null = null; let hexSize = 48; const origin = { x: 0, y: 0 }; +let currentMapId: number | null = null; +let saveTimeout: ReturnType | null = null; // --- Init Map --- const map = initMap('map'); @@ -41,7 +44,7 @@ hexLayer.addTo(map); const sidebarEl = document.getElementById('sidebar')!; const { toolbar, terrainPicker, hexInspector, settings } = createSidebar(sidebarEl); -const toolbarUI = createToolbar(toolbar, (mode) => { +createToolbar(toolbar, (mode) => { currentMode = mode; terrainPickerUI.setMode(mode); }); @@ -76,6 +79,28 @@ function rebuildHexLayer(showGrid: boolean, opacity: number) { reattachInteraction(); } +// --- Auto-save (debounced) --- +function scheduleSave() { + if (!currentMapId) return; + if (saveTimeout) clearTimeout(saveTimeout); + saveTimeout = setTimeout(async () => { + if (!currentMapId || !hexMap.dirty) return; + try { + const data = hexMap.serialize(); + await api.saveHexes(currentMapId, data.hexes.map(h => ({ + q: h.q, + r: h.r, + base: h.base, + features: h.features.map(f => ({ terrainId: f.terrainId, edgeMask: f.edgeMask })), + }))); + hexMap.markClean(); + console.log(`[save] Saved ${data.hexes.length} hexes`); + } catch (err) { + console.error('[save] Failed:', err); + } + }, 1000); +} + // --- Interaction handlers --- function handleSelect(event: HexClickEvent) { @@ -90,6 +115,7 @@ function handlePaint(event: HexClickEvent) { hexLayer.redraw(); selectedHex = event.coord; hexInspectorUI.update(selectedHex, hexMap.getTerrain(selectedHex)); + scheduleSave(); } function handleFeature(event: HexClickEvent) { @@ -112,6 +138,7 @@ function handleFeature(event: HexClickEvent) { hexLayer.setSelectedHex(selectedHex); hexLayer.redraw(); hexInspectorUI.update(selectedHex, hexMap.getTerrain(selectedHex)); + scheduleSave(); } // --- Hex Interaction --- @@ -121,13 +148,11 @@ function reattachInteraction() { detachInteraction?.(); detachInteraction = attachHexInteraction( map, hexSize, origin, - // Click handler (event) => { if (currentMode === 'select') handleSelect(event); else if (currentMode === 'paint') handlePaint(event); else if (currentMode === 'feature') handleFeature(event); }, - // Drag-paint handler (only active in paint mode) (event) => { if (currentMode === 'paint') handlePaint(event); }, @@ -135,3 +160,36 @@ function reattachInteraction() { } reattachInteraction(); + +// --- Load or create map from API --- +async function init() { + try { + const maps = await api.listMaps(); + if (maps.length > 0) { + currentMapId = maps[0].id; + console.log(`[init] Loading map "${maps[0].name}" (id: ${currentMapId})`); + } else { + const result = await api.createMap({ name: 'Default Map' }); + currentMapId = result.id; + console.log(`[init] Created new map (id: ${currentMapId})`); + } + + // Load existing hexes + const hexes = await api.loadHexes(currentMapId); + if (hexes.length > 0) { + for (const hex of hexes) { + hexMap.setTerrain({ q: hex.q, r: hex.r }, { + base: hex.base, + features: hex.features, + }); + } + hexMap.markClean(); + hexLayer.redraw(); + console.log(`[init] Loaded ${hexes.length} hexes`); + } + } catch (err) { + console.warn('[init] API not available, running without persistence:', err); + } +} + +init(); diff --git a/vite.config.ts b/vite.config.ts index 4d98045..e562867 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -16,11 +16,11 @@ export default defineConfig({ allowedHosts: ['hexifyer.davoryn.de'], proxy: { '/api': { - target: 'http://localhost:3001', + target: 'http://localhost:3002', changeOrigin: true, }, '/tiles': { - target: 'http://localhost:3001', + target: 'http://localhost:3002', changeOrigin: true, }, },